@bygghammar/cms-platform 0.4.0 → 0.6.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/package.json +56 -3
- package/src/admin/format-log.ts +217 -0
- package/src/backoffice/PreviewLightbox.tsx +66 -0
- package/src/backoffice/ProgressBar.tsx +36 -0
- package/src/backoffice/RichTextEditor.tsx +633 -0
- package/src/backoffice/forms/ActivityLog.tsx +153 -0
- package/src/backoffice/forms/ConditionBuilder.tsx +170 -0
- package/src/backoffice/forms/RuleSimulator.tsx +163 -0
- package/src/backoffice/forms/SmartTags.tsx +181 -0
- package/src/backoffice/media/ConfirmDialog.tsx +77 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { EditorContent, useEditor, type Editor } from "@tiptap/react";
|
|
3
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
+
import Image from "@tiptap/extension-image";
|
|
5
|
+
import TextAlign from "@tiptap/extension-text-align";
|
|
6
|
+
import Highlight from "@tiptap/extension-highlight";
|
|
7
|
+
import Subscript from "@tiptap/extension-subscript";
|
|
8
|
+
import Superscript from "@tiptap/extension-superscript";
|
|
9
|
+
import { Placeholder } from "@tiptap/extensions";
|
|
10
|
+
import { Color, FontFamily, FontSize, LineHeight, TextStyle } from "@tiptap/extension-text-style";
|
|
11
|
+
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
|
12
|
+
import {
|
|
13
|
+
AlignCenter,
|
|
14
|
+
AlignJustify,
|
|
15
|
+
AlignLeft,
|
|
16
|
+
AlignRight,
|
|
17
|
+
Bold,
|
|
18
|
+
Code,
|
|
19
|
+
Code2,
|
|
20
|
+
Eraser,
|
|
21
|
+
Image as ImageIcon,
|
|
22
|
+
Italic,
|
|
23
|
+
Link as LinkIcon,
|
|
24
|
+
List,
|
|
25
|
+
ListOrdered,
|
|
26
|
+
Minus,
|
|
27
|
+
Quote,
|
|
28
|
+
Redo2,
|
|
29
|
+
Strikethrough,
|
|
30
|
+
Subscript as SubIcon,
|
|
31
|
+
Superscript as SupIcon,
|
|
32
|
+
Table as TableIcon,
|
|
33
|
+
Underline as UnderlineIcon,
|
|
34
|
+
Undo2,
|
|
35
|
+
Unlink,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
import { MediaPickerDialog } from "./inspectors/fields";
|
|
38
|
+
import { sanitizeHtml } from "../html/sanitize-html";
|
|
39
|
+
|
|
40
|
+
const FONTS: { label: string; value: string }[] = [
|
|
41
|
+
{ label: "Theme (default)", value: "" },
|
|
42
|
+
{ label: "Serif (display)", value: "var(--font-serif)" },
|
|
43
|
+
{ label: "Sans (body)", value: "var(--font-sans)" },
|
|
44
|
+
{ label: "Georgia", value: "Georgia, serif" },
|
|
45
|
+
{ label: "Times New Roman", value: "'Times New Roman', serif" },
|
|
46
|
+
{ label: "Helvetica", value: "Helvetica, Arial, sans-serif" },
|
|
47
|
+
{ label: "Arial", value: "Arial, sans-serif" },
|
|
48
|
+
{ label: "Courier", value: "'Courier New', monospace" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const SIZES = ["", "12px", "14px", "16px", "18px", "20px", "24px", "30px", "36px", "48px", "60px"];
|
|
52
|
+
|
|
53
|
+
const SWATCHES = [
|
|
54
|
+
"#0f1720",
|
|
55
|
+
"#4b5563",
|
|
56
|
+
"#9ca3af",
|
|
57
|
+
"#ffffff",
|
|
58
|
+
"#2ac4ea",
|
|
59
|
+
"#d8f2fd",
|
|
60
|
+
"#afafaf",
|
|
61
|
+
"#0ea5e9",
|
|
62
|
+
"#16a34a",
|
|
63
|
+
"#eab308",
|
|
64
|
+
"#f97316",
|
|
65
|
+
"#dc2626",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function Btn({
|
|
69
|
+
on,
|
|
70
|
+
active,
|
|
71
|
+
title,
|
|
72
|
+
disabled,
|
|
73
|
+
children,
|
|
74
|
+
}: {
|
|
75
|
+
on: () => void;
|
|
76
|
+
active?: boolean;
|
|
77
|
+
title: string;
|
|
78
|
+
disabled?: boolean;
|
|
79
|
+
children: React.ReactNode;
|
|
80
|
+
}) {
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
title={title}
|
|
85
|
+
aria-label={title}
|
|
86
|
+
aria-pressed={active}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
89
|
+
onClick={on}
|
|
90
|
+
className={[
|
|
91
|
+
"flex size-7 items-center justify-center border text-ink transition-colors disabled:opacity-35",
|
|
92
|
+
active ? "border-sea bg-sea/15" : "border-transparent hover:border-ink/20",
|
|
93
|
+
].join(" ")}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Divider() {
|
|
101
|
+
return <span className="mx-1 h-5 w-px bg-ink/10" />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ColorControl({
|
|
105
|
+
label,
|
|
106
|
+
value,
|
|
107
|
+
onPick,
|
|
108
|
+
onClear,
|
|
109
|
+
}: {
|
|
110
|
+
label: string;
|
|
111
|
+
value: string;
|
|
112
|
+
onPick: (color: string) => void;
|
|
113
|
+
onClear: () => void;
|
|
114
|
+
}) {
|
|
115
|
+
const [open, setOpen] = useState(false);
|
|
116
|
+
return (
|
|
117
|
+
<div className="relative">
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
title={label}
|
|
121
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
122
|
+
onClick={() => setOpen((o) => !o)}
|
|
123
|
+
className="flex h-7 items-center gap-1 border border-transparent px-1.5 text-[0.65rem] uppercase tracking-widest text-ink hover:border-ink/20"
|
|
124
|
+
>
|
|
125
|
+
{label}
|
|
126
|
+
<span
|
|
127
|
+
className="size-3 border border-ink/25"
|
|
128
|
+
style={{ background: value || "transparent" }}
|
|
129
|
+
/>
|
|
130
|
+
</button>
|
|
131
|
+
{open ? (
|
|
132
|
+
<>
|
|
133
|
+
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
|
134
|
+
<div className="absolute left-0 top-8 z-50 w-44 border border-ink/15 bg-white p-2 shadow-lg">
|
|
135
|
+
<div className="grid grid-cols-6 gap-1">
|
|
136
|
+
{SWATCHES.map((c) => (
|
|
137
|
+
<button
|
|
138
|
+
key={c}
|
|
139
|
+
type="button"
|
|
140
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
141
|
+
onClick={() => {
|
|
142
|
+
onPick(c);
|
|
143
|
+
setOpen(false);
|
|
144
|
+
}}
|
|
145
|
+
className="size-5 border border-ink/15"
|
|
146
|
+
style={{ background: c }}
|
|
147
|
+
title={c}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
<div className="mt-2 flex items-center gap-2">
|
|
152
|
+
<input
|
|
153
|
+
type="color"
|
|
154
|
+
value={value || "#000000"}
|
|
155
|
+
onChange={(e) => onPick(e.target.value)}
|
|
156
|
+
className="h-6 w-10 border border-ink/15 bg-white"
|
|
157
|
+
/>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
161
|
+
onClick={() => {
|
|
162
|
+
onClear();
|
|
163
|
+
setOpen(false);
|
|
164
|
+
}}
|
|
165
|
+
className="text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
|
|
166
|
+
>
|
|
167
|
+
Clear
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</>
|
|
172
|
+
) : null}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function Toolbar({ editor }: { editor: Editor }) {
|
|
178
|
+
const [pickImage, setPickImage] = useState(false);
|
|
179
|
+
|
|
180
|
+
const setLink = () => {
|
|
181
|
+
const previous = (editor.getAttributes("link").href as string) ?? "";
|
|
182
|
+
const url = window.prompt("Link URL (https://…, /page, mailto:, tel:)", previous);
|
|
183
|
+
if (url === null) return;
|
|
184
|
+
if (!url.trim()) {
|
|
185
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
editor.chain().focus().extendMarkRange("link").setLink({ href: url.trim() }).run();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const headingValue = (() => {
|
|
192
|
+
for (const level of [1, 2, 3, 4, 5, 6] as const) {
|
|
193
|
+
if (editor.isActive("heading", { level })) return `h${level}`;
|
|
194
|
+
}
|
|
195
|
+
return editor.isActive("codeBlock") ? "code" : "p";
|
|
196
|
+
})();
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-0.5 border-b border-ink/10 bg-white/95 p-1.5 backdrop-blur">
|
|
200
|
+
<Btn
|
|
201
|
+
on={() => editor.chain().focus().undo().run()}
|
|
202
|
+
title="Undo"
|
|
203
|
+
disabled={!editor.can().undo()}
|
|
204
|
+
>
|
|
205
|
+
<Undo2 className="size-3.5" />
|
|
206
|
+
</Btn>
|
|
207
|
+
<Btn
|
|
208
|
+
on={() => editor.chain().focus().redo().run()}
|
|
209
|
+
title="Redo"
|
|
210
|
+
disabled={!editor.can().redo()}
|
|
211
|
+
>
|
|
212
|
+
<Redo2 className="size-3.5" />
|
|
213
|
+
</Btn>
|
|
214
|
+
<Divider />
|
|
215
|
+
|
|
216
|
+
<select
|
|
217
|
+
value={headingValue}
|
|
218
|
+
onChange={(e) => {
|
|
219
|
+
const v = e.target.value;
|
|
220
|
+
const chain = editor.chain().focus();
|
|
221
|
+
if (v === "p") chain.setParagraph().run();
|
|
222
|
+
else if (v === "code") chain.toggleCodeBlock().run();
|
|
223
|
+
else chain.setHeading({ level: Number(v.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6 }).run();
|
|
224
|
+
}}
|
|
225
|
+
className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
|
|
226
|
+
title="Text style"
|
|
227
|
+
>
|
|
228
|
+
<option value="p">Paragraph</option>
|
|
229
|
+
<option value="h1">Heading 1</option>
|
|
230
|
+
<option value="h2">Heading 2</option>
|
|
231
|
+
<option value="h3">Heading 3</option>
|
|
232
|
+
<option value="h4">Heading 4</option>
|
|
233
|
+
<option value="h5">Heading 5</option>
|
|
234
|
+
<option value="h6">Heading 6</option>
|
|
235
|
+
<option value="code">Code block</option>
|
|
236
|
+
</select>
|
|
237
|
+
|
|
238
|
+
<select
|
|
239
|
+
value={(editor.getAttributes("textStyle").fontFamily as string) ?? ""}
|
|
240
|
+
onChange={(e) => {
|
|
241
|
+
const v = e.target.value;
|
|
242
|
+
if (!v) editor.chain().focus().unsetFontFamily().run();
|
|
243
|
+
else editor.chain().focus().setFontFamily(v).run();
|
|
244
|
+
}}
|
|
245
|
+
className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
|
|
246
|
+
title="Font"
|
|
247
|
+
>
|
|
248
|
+
{FONTS.map((f) => (
|
|
249
|
+
<option key={f.label} value={f.value}>
|
|
250
|
+
{f.label}
|
|
251
|
+
</option>
|
|
252
|
+
))}
|
|
253
|
+
</select>
|
|
254
|
+
|
|
255
|
+
<select
|
|
256
|
+
value={(editor.getAttributes("textStyle").fontSize as string) ?? ""}
|
|
257
|
+
onChange={(e) => {
|
|
258
|
+
const v = e.target.value;
|
|
259
|
+
if (!v) editor.chain().focus().unsetFontSize().run();
|
|
260
|
+
else editor.chain().focus().setFontSize(v).run();
|
|
261
|
+
}}
|
|
262
|
+
className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
|
|
263
|
+
title="Font size"
|
|
264
|
+
>
|
|
265
|
+
{SIZES.map((s) => (
|
|
266
|
+
<option key={s || "default"} value={s}>
|
|
267
|
+
{s || "Size"}
|
|
268
|
+
</option>
|
|
269
|
+
))}
|
|
270
|
+
</select>
|
|
271
|
+
<Divider />
|
|
272
|
+
|
|
273
|
+
<Btn
|
|
274
|
+
on={() => editor.chain().focus().toggleBold().run()}
|
|
275
|
+
active={editor.isActive("bold")}
|
|
276
|
+
title="Bold"
|
|
277
|
+
>
|
|
278
|
+
<Bold className="size-3.5" />
|
|
279
|
+
</Btn>
|
|
280
|
+
<Btn
|
|
281
|
+
on={() => editor.chain().focus().toggleItalic().run()}
|
|
282
|
+
active={editor.isActive("italic")}
|
|
283
|
+
title="Italic"
|
|
284
|
+
>
|
|
285
|
+
<Italic className="size-3.5" />
|
|
286
|
+
</Btn>
|
|
287
|
+
<Btn
|
|
288
|
+
on={() => editor.chain().focus().toggleUnderline().run()}
|
|
289
|
+
active={editor.isActive("underline")}
|
|
290
|
+
title="Underline"
|
|
291
|
+
>
|
|
292
|
+
<UnderlineIcon className="size-3.5" />
|
|
293
|
+
</Btn>
|
|
294
|
+
<Btn
|
|
295
|
+
on={() => editor.chain().focus().toggleStrike().run()}
|
|
296
|
+
active={editor.isActive("strike")}
|
|
297
|
+
title="Strikethrough"
|
|
298
|
+
>
|
|
299
|
+
<Strikethrough className="size-3.5" />
|
|
300
|
+
</Btn>
|
|
301
|
+
<Btn
|
|
302
|
+
on={() => editor.chain().focus().toggleCode().run()}
|
|
303
|
+
active={editor.isActive("code")}
|
|
304
|
+
title="Inline code"
|
|
305
|
+
>
|
|
306
|
+
<Code className="size-3.5" />
|
|
307
|
+
</Btn>
|
|
308
|
+
<Btn
|
|
309
|
+
on={() => editor.chain().focus().toggleSubscript().run()}
|
|
310
|
+
active={editor.isActive("subscript")}
|
|
311
|
+
title="Subscript"
|
|
312
|
+
>
|
|
313
|
+
<SubIcon className="size-3.5" />
|
|
314
|
+
</Btn>
|
|
315
|
+
<Btn
|
|
316
|
+
on={() => editor.chain().focus().toggleSuperscript().run()}
|
|
317
|
+
active={editor.isActive("superscript")}
|
|
318
|
+
title="Superscript"
|
|
319
|
+
>
|
|
320
|
+
<SupIcon className="size-3.5" />
|
|
321
|
+
</Btn>
|
|
322
|
+
<Divider />
|
|
323
|
+
|
|
324
|
+
<ColorControl
|
|
325
|
+
label="A"
|
|
326
|
+
value={(editor.getAttributes("textStyle").color as string) ?? ""}
|
|
327
|
+
onPick={(c) => editor.chain().focus().setColor(c).run()}
|
|
328
|
+
onClear={() => editor.chain().focus().unsetColor().run()}
|
|
329
|
+
/>
|
|
330
|
+
<ColorControl
|
|
331
|
+
label="BG"
|
|
332
|
+
value={(editor.getAttributes("highlight").color as string) ?? ""}
|
|
333
|
+
onPick={(c) => editor.chain().focus().setHighlight({ color: c }).run()}
|
|
334
|
+
onClear={() => editor.chain().focus().unsetHighlight().run()}
|
|
335
|
+
/>
|
|
336
|
+
<Divider />
|
|
337
|
+
|
|
338
|
+
<Btn
|
|
339
|
+
on={() => editor.chain().focus().setTextAlign("left").run()}
|
|
340
|
+
active={editor.isActive({ textAlign: "left" })}
|
|
341
|
+
title="Align left"
|
|
342
|
+
>
|
|
343
|
+
<AlignLeft className="size-3.5" />
|
|
344
|
+
</Btn>
|
|
345
|
+
<Btn
|
|
346
|
+
on={() => editor.chain().focus().setTextAlign("center").run()}
|
|
347
|
+
active={editor.isActive({ textAlign: "center" })}
|
|
348
|
+
title="Align center"
|
|
349
|
+
>
|
|
350
|
+
<AlignCenter className="size-3.5" />
|
|
351
|
+
</Btn>
|
|
352
|
+
<Btn
|
|
353
|
+
on={() => editor.chain().focus().setTextAlign("right").run()}
|
|
354
|
+
active={editor.isActive({ textAlign: "right" })}
|
|
355
|
+
title="Align right"
|
|
356
|
+
>
|
|
357
|
+
<AlignRight className="size-3.5" />
|
|
358
|
+
</Btn>
|
|
359
|
+
<Btn
|
|
360
|
+
on={() => editor.chain().focus().setTextAlign("justify").run()}
|
|
361
|
+
active={editor.isActive({ textAlign: "justify" })}
|
|
362
|
+
title="Justify"
|
|
363
|
+
>
|
|
364
|
+
<AlignJustify className="size-3.5" />
|
|
365
|
+
</Btn>
|
|
366
|
+
<select
|
|
367
|
+
value={(editor.getAttributes("paragraph").lineHeight as string) ?? ""}
|
|
368
|
+
onChange={(e) => {
|
|
369
|
+
const v = e.target.value;
|
|
370
|
+
if (!v) editor.chain().focus().unsetLineHeight().run();
|
|
371
|
+
else editor.chain().focus().setLineHeight(v).run();
|
|
372
|
+
}}
|
|
373
|
+
className="h-7 border border-ink/15 bg-white px-1 text-xs text-ink focus:border-sea focus:outline-none"
|
|
374
|
+
title="Line height"
|
|
375
|
+
>
|
|
376
|
+
<option value="">Line height</option>
|
|
377
|
+
{["1", "1.25", "1.5", "1.75", "2"].map((v) => (
|
|
378
|
+
<option key={v} value={v}>
|
|
379
|
+
{v}
|
|
380
|
+
</option>
|
|
381
|
+
))}
|
|
382
|
+
</select>
|
|
383
|
+
<Divider />
|
|
384
|
+
|
|
385
|
+
<Btn
|
|
386
|
+
on={() => editor.chain().focus().toggleBulletList().run()}
|
|
387
|
+
active={editor.isActive("bulletList")}
|
|
388
|
+
title="Bullet list"
|
|
389
|
+
>
|
|
390
|
+
<List className="size-3.5" />
|
|
391
|
+
</Btn>
|
|
392
|
+
<Btn
|
|
393
|
+
on={() => editor.chain().focus().toggleOrderedList().run()}
|
|
394
|
+
active={editor.isActive("orderedList")}
|
|
395
|
+
title="Numbered list"
|
|
396
|
+
>
|
|
397
|
+
<ListOrdered className="size-3.5" />
|
|
398
|
+
</Btn>
|
|
399
|
+
<Btn
|
|
400
|
+
on={() => editor.chain().focus().toggleBlockquote().run()}
|
|
401
|
+
active={editor.isActive("blockquote")}
|
|
402
|
+
title="Quote"
|
|
403
|
+
>
|
|
404
|
+
<Quote className="size-3.5" />
|
|
405
|
+
</Btn>
|
|
406
|
+
<Btn on={() => editor.chain().focus().setHorizontalRule().run()} title="Divider">
|
|
407
|
+
<Minus className="size-3.5" />
|
|
408
|
+
</Btn>
|
|
409
|
+
<Divider />
|
|
410
|
+
|
|
411
|
+
<Btn on={setLink} active={editor.isActive("link")} title="Add or edit link">
|
|
412
|
+
<LinkIcon className="size-3.5" />
|
|
413
|
+
</Btn>
|
|
414
|
+
<Btn
|
|
415
|
+
on={() => editor.chain().focus().extendMarkRange("link").unsetLink().run()}
|
|
416
|
+
title="Remove link"
|
|
417
|
+
disabled={!editor.isActive("link")}
|
|
418
|
+
>
|
|
419
|
+
<Unlink className="size-3.5" />
|
|
420
|
+
</Btn>
|
|
421
|
+
<Btn on={() => setPickImage(true)} title="Insert image from the media library">
|
|
422
|
+
<ImageIcon className="size-3.5" />
|
|
423
|
+
</Btn>
|
|
424
|
+
<Divider />
|
|
425
|
+
|
|
426
|
+
<Btn
|
|
427
|
+
on={() =>
|
|
428
|
+
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
|
429
|
+
}
|
|
430
|
+
title="Insert table"
|
|
431
|
+
>
|
|
432
|
+
<TableIcon className="size-3.5" />
|
|
433
|
+
</Btn>
|
|
434
|
+
{editor.isActive("table") ? (
|
|
435
|
+
<div className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60">
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
439
|
+
onClick={() => editor.chain().focus().addRowAfter().run()}
|
|
440
|
+
className="hover:text-ink"
|
|
441
|
+
>
|
|
442
|
+
+Row
|
|
443
|
+
</button>
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
447
|
+
onClick={() => editor.chain().focus().addColumnAfter().run()}
|
|
448
|
+
className="hover:text-ink"
|
|
449
|
+
>
|
|
450
|
+
+Col
|
|
451
|
+
</button>
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
455
|
+
onClick={() => editor.chain().focus().deleteRow().run()}
|
|
456
|
+
className="hover:text-ink"
|
|
457
|
+
>
|
|
458
|
+
-Row
|
|
459
|
+
</button>
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
463
|
+
onClick={() => editor.chain().focus().deleteColumn().run()}
|
|
464
|
+
className="hover:text-ink"
|
|
465
|
+
>
|
|
466
|
+
-Col
|
|
467
|
+
</button>
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
471
|
+
onClick={() => editor.chain().focus().deleteTable().run()}
|
|
472
|
+
className="text-red-500 hover:text-red-700"
|
|
473
|
+
>
|
|
474
|
+
Del table
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
) : null}
|
|
478
|
+
<Divider />
|
|
479
|
+
|
|
480
|
+
<Btn
|
|
481
|
+
on={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
|
|
482
|
+
title="Clear formatting"
|
|
483
|
+
>
|
|
484
|
+
<Eraser className="size-3.5" />
|
|
485
|
+
</Btn>
|
|
486
|
+
|
|
487
|
+
{pickImage ? (
|
|
488
|
+
<MediaPickerDialog
|
|
489
|
+
accept="image"
|
|
490
|
+
onClose={() => setPickImage(false)}
|
|
491
|
+
onPick={(m) => {
|
|
492
|
+
editor.chain().focus().setImage({ src: m.url, alt: m.name }).run();
|
|
493
|
+
}}
|
|
494
|
+
/>
|
|
495
|
+
) : null}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Advanced WYSIWYG editor (TipTap) used by the rich-text block.
|
|
502
|
+
* Emits sanitized HTML; also offers a raw HTML source view for pasting
|
|
503
|
+
* markup from elsewhere.
|
|
504
|
+
*/
|
|
505
|
+
export function RichTextEditor({
|
|
506
|
+
value,
|
|
507
|
+
onChange,
|
|
508
|
+
placeholder = "Write or paste your text…",
|
|
509
|
+
}: {
|
|
510
|
+
value: string;
|
|
511
|
+
onChange: (html: string) => void;
|
|
512
|
+
placeholder?: string;
|
|
513
|
+
}) {
|
|
514
|
+
const [source, setSource] = useState(false);
|
|
515
|
+
const [draft, setDraft] = useState(value);
|
|
516
|
+
const last = useRef(value);
|
|
517
|
+
|
|
518
|
+
const editor = useEditor({
|
|
519
|
+
immediatelyRender: false,
|
|
520
|
+
extensions: [
|
|
521
|
+
StarterKit.configure({
|
|
522
|
+
link: {
|
|
523
|
+
openOnClick: false,
|
|
524
|
+
autolink: true,
|
|
525
|
+
HTMLAttributes: { rel: "noopener noreferrer" },
|
|
526
|
+
},
|
|
527
|
+
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
|
528
|
+
}),
|
|
529
|
+
TextStyle,
|
|
530
|
+
Color,
|
|
531
|
+
FontFamily,
|
|
532
|
+
FontSize,
|
|
533
|
+
LineHeight,
|
|
534
|
+
Highlight.configure({ multicolor: true }),
|
|
535
|
+
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
536
|
+
Subscript,
|
|
537
|
+
Superscript,
|
|
538
|
+
Image.configure({ inline: false, allowBase64: false }),
|
|
539
|
+
Table.configure({ resizable: true }),
|
|
540
|
+
TableRow,
|
|
541
|
+
TableHeader,
|
|
542
|
+
TableCell,
|
|
543
|
+
Placeholder.configure({ placeholder }),
|
|
544
|
+
],
|
|
545
|
+
content: value || "",
|
|
546
|
+
editorProps: {
|
|
547
|
+
attributes: {
|
|
548
|
+
class: "rte-content focus:outline-none",
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
onUpdate: ({ editor: e }) => {
|
|
552
|
+
const html = sanitizeHtml(e.getHTML());
|
|
553
|
+
last.current = html;
|
|
554
|
+
onChange(html);
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// Adopt external changes (locale switch, version rollback) without
|
|
559
|
+
// clobbering what the user is typing.
|
|
560
|
+
useEffect(() => {
|
|
561
|
+
if (!editor) return;
|
|
562
|
+
if (value === last.current) return;
|
|
563
|
+
last.current = value;
|
|
564
|
+
editor.commands.setContent(value || "", { emitUpdate: false });
|
|
565
|
+
setDraft(value);
|
|
566
|
+
}, [value, editor]);
|
|
567
|
+
|
|
568
|
+
const applySource = useCallback(() => {
|
|
569
|
+
const html = sanitizeHtml(draft);
|
|
570
|
+
last.current = html;
|
|
571
|
+
onChange(html);
|
|
572
|
+
editor?.commands.setContent(html || "", { emitUpdate: false });
|
|
573
|
+
setSource(false);
|
|
574
|
+
}, [draft, editor, onChange]);
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
<div className="rte-editor border border-ink/15 bg-white">
|
|
578
|
+
{editor ? <Toolbar editor={editor} /> : null}
|
|
579
|
+
{source ? (
|
|
580
|
+
<div className="p-2">
|
|
581
|
+
<textarea
|
|
582
|
+
value={draft}
|
|
583
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
584
|
+
spellCheck={false}
|
|
585
|
+
rows={16}
|
|
586
|
+
className="w-full border border-ink/15 p-2 font-mono text-xs text-ink focus:border-sea focus:outline-none"
|
|
587
|
+
/>
|
|
588
|
+
<div className="mt-2 flex gap-2">
|
|
589
|
+
<button
|
|
590
|
+
type="button"
|
|
591
|
+
onClick={applySource}
|
|
592
|
+
className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest hover:border-sea"
|
|
593
|
+
>
|
|
594
|
+
Apply HTML
|
|
595
|
+
</button>
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
onClick={() => {
|
|
599
|
+
setDraft(value);
|
|
600
|
+
setSource(false);
|
|
601
|
+
}}
|
|
602
|
+
className="px-3 py-1.5 text-xs uppercase tracking-widest text-ink/60 hover:text-ink"
|
|
603
|
+
>
|
|
604
|
+
Cancel
|
|
605
|
+
</button>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
) : (
|
|
609
|
+
<div className="max-h-[32rem] overflow-auto p-4">
|
|
610
|
+
<EditorContent editor={editor} />
|
|
611
|
+
</div>
|
|
612
|
+
)}
|
|
613
|
+
<div className="flex items-center justify-between border-t border-ink/10 px-2 py-1">
|
|
614
|
+
<span className="text-[0.65rem] uppercase tracking-widest text-ink/40">
|
|
615
|
+
{editor
|
|
616
|
+
? `${editor.storage.characterCount?.characters?.() ?? editor.getText().length} chars`
|
|
617
|
+
: ""}
|
|
618
|
+
</span>
|
|
619
|
+
<button
|
|
620
|
+
type="button"
|
|
621
|
+
onClick={() => {
|
|
622
|
+
setDraft(value);
|
|
623
|
+
setSource((s) => !s);
|
|
624
|
+
}}
|
|
625
|
+
className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
|
|
626
|
+
>
|
|
627
|
+
<Code2 className="size-3" />
|
|
628
|
+
{source ? "Visual editor" : "HTML source"}
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
);
|
|
633
|
+
}
|