@intinyagroup/rich-text 0.1.1-alpha.10

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,1223 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { Editor, Node, mergeAttributes } from '@tiptap/core';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import Placeholder from '@tiptap/extension-placeholder';
6
+ import Underline from '@tiptap/extension-underline';
7
+ import TextAlign from '@tiptap/extension-text-align';
8
+ import Link from '@tiptap/extension-link';
9
+ import Image from '@tiptap/extension-image';
10
+ import Highlight from '@tiptap/extension-highlight';
11
+ import Table from '@tiptap/extension-table';
12
+ import TableRow from '@tiptap/extension-table-row';
13
+ import TableCell from '@tiptap/extension-table-cell';
14
+ import TableHeader from '@tiptap/extension-table-header';
15
+ import Typography from '@tiptap/extension-typography';
16
+ import BubbleMenu from '@tiptap/extension-bubble-menu';
17
+ import TaskList from '@tiptap/extension-task-list';
18
+ import TaskItem from '@tiptap/extension-task-item';
19
+ import {
20
+ Bold, Italic, Underline as UnderlineIcon, Strikethrough, Highlighter,
21
+ AlignLeft, AlignCenter, AlignRight, AlignJustify,
22
+ List, ListOrdered, Quote, Code, Minus, Link as LinkIcon, Image as ImageIcon,
23
+ Undo, Redo, Heading1, Heading2, Heading3, TableIcon, Plus, Trash2,
24
+ Video, CheckSquare, Info, AlertTriangle, Sparkles, HelpCircle, FileText
25
+ } from 'lucide-svelte';
26
+ import { Button, Separator } from '@intinyagroup/ui';
27
+ import { cn } from '@intinyagroup/grid-core/utils';
28
+
29
+ let {
30
+ content = '',
31
+ placeholder = 'Start writing...',
32
+ editable = true,
33
+ mode = 'classic',
34
+ height = 400,
35
+ class: className,
36
+ onUpdate,
37
+ onImageUpload,
38
+ onOpenSubPage,
39
+ }: {
40
+ content?: string;
41
+ placeholder?: string;
42
+ editable?: boolean;
43
+ /** Toolbar mode: 'classic' (fixed top toolbar), 'bubble' (Notion-style floating toolbar only), or 'none' */
44
+ mode?: 'classic' | 'bubble' | 'none';
45
+ height?: number;
46
+ class?: string;
47
+ onUpdate?: (html: string) => void;
48
+ /** Called when paste/drop provides an image file. Return a URL to insert. */
49
+ onImageUpload?: (file: File) => Promise<string>;
50
+ onOpenSubPage?: (page: { id: string; title: string }) => void;
51
+ } = $props();
52
+
53
+ let editorEl: HTMLDivElement | null = null;
54
+ let bubbleMenuEl: HTMLDivElement | null = null;
55
+ let editor: Editor | null = null;
56
+ let isActive = $state<Record<string, boolean>>({});
57
+ let uploadingCount = $state(0);
58
+
59
+ // Slash commands state
60
+ let showSlashMenu = $state(false);
61
+ let slashSearch = $state('');
62
+ let slashIndex = $state(0);
63
+ let slashMenuPos = $state({ top: 0, left: 0 });
64
+
65
+ const slashCommands = [
66
+ { title: 'Sub-page', desc: 'Embed a sub-page inside this page', icon: FileText, action: () => insertSubPage() },
67
+ { title: 'Heading 1', desc: 'Big section heading', icon: Heading1, action: () => setHeading(1) },
68
+ { title: 'Heading 2', desc: 'Medium section heading', icon: Heading2, action: () => setHeading(2) },
69
+ { title: 'Heading 3', desc: 'Small subsection heading', icon: Heading3, action: () => setHeading(3) },
70
+ { title: 'To-do list', desc: 'Track tasks with a checklist', icon: CheckSquare, action: () => editor?.chain().focus().toggleTaskList().run() },
71
+ { title: 'Bullet list', desc: 'Create a bulleted list', icon: List, action: () => toggleBulletList() },
72
+ { title: 'Numbered list', desc: 'Create a numbered list', icon: ListOrdered, action: () => toggleOrderedList() },
73
+ { title: 'Quote', desc: 'Capture a quote or blockquote', icon: Quote, action: () => toggleBlockquote() },
74
+ { title: 'Code block', desc: 'Code snippet with syntax highlight', icon: Code, action: () => toggleCodeBlock() },
75
+ { title: 'Callout Info', desc: 'Informational highlight block', icon: Info, action: () => insertCallout('info') },
76
+ { title: 'Callout Warning', desc: 'Warning or caution block', icon: AlertTriangle, action: () => insertCallout('warning') },
77
+ { title: 'Callout Tip', desc: 'Tip or recommendation block', icon: Sparkles, action: () => insertCallout('tip') },
78
+ { title: 'Table', desc: 'Insert 3x3 table grid', icon: TableIcon, action: () => insertTable() },
79
+ { title: 'Divider', desc: 'Visually divide sections', icon: Minus, action: () => insertHorizontalRule() },
80
+ ];
81
+
82
+ const slashFilteredCommands = $derived(
83
+ slashCommands.filter((c) =>
84
+ c.title.toLowerCase().includes(slashSearch.toLowerCase()) ||
85
+ c.desc.toLowerCase().includes(slashSearch.toLowerCase())
86
+ )
87
+ );
88
+ // ---------------------------------------------------------------------------
89
+ // Custom Extensions
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** Image with href/alt/title attributes — renders as <a><img></a> when href set */
93
+ const CustomImage = Image.extend({
94
+ addAttributes() {
95
+ return {
96
+ ...this.parent?.(),
97
+ href: { default: null },
98
+ alt: { default: '' },
99
+ title: { default: '' },
100
+ target: { default: '_blank' },
101
+ };
102
+ },
103
+ renderHTML({ HTMLAttributes }) {
104
+ const { href, target, alt, title, ...rest } = HTMLAttributes;
105
+ const img = ['img', mergeAttributes(this.options.HTMLAttributes, rest, { alt, title })];
106
+ if (href) {
107
+ return ['a', { href, target, class: 'image-link' }, img];
108
+ }
109
+ return img;
110
+ },
111
+ });
112
+
113
+ /** YouTube iframe embed node */
114
+ const CustomYoutube = Node.create({
115
+ name: 'youtube',
116
+ group: 'block',
117
+ atom: true,
118
+ addAttributes() {
119
+ return { src: { default: null } };
120
+ },
121
+ parseHTML() {
122
+ return [
123
+ { tag: "iframe[src*='youtube.com']" },
124
+ { tag: "iframe[src*='youtu.be']" },
125
+ ];
126
+ },
127
+ renderHTML({ HTMLAttributes }) {
128
+ return [
129
+ 'div',
130
+ { class: 'video-wrapper' },
131
+ [
132
+ 'iframe',
133
+ mergeAttributes(HTMLAttributes, {
134
+ width: '100%',
135
+ height: '315',
136
+ allowfullscreen: 'true',
137
+ }),
138
+ ],
139
+ ];
140
+ },
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Image Compression
145
+ // ---------------------------------------------------------------------------
146
+
147
+ async function compressImage(file: File): Promise<File> {
148
+ if (file.size < 100 * 1024) return file;
149
+
150
+ return new Promise((resolve) => {
151
+ const reader = new FileReader();
152
+ reader.onload = (e) => {
153
+ const img = new Image();
154
+ img.onload = () => {
155
+ const canvas = document.createElement('canvas');
156
+ const ctx = canvas.getContext('2d');
157
+ if (!ctx) {
158
+ resolve(file);
159
+ return;
160
+ }
161
+
162
+ let { width, height } = img;
163
+ const maxDim = 2048;
164
+
165
+ if (width > maxDim || height > maxDim) {
166
+ if (width > height) {
167
+ height = (height / width) * maxDim;
168
+ width = maxDim;
169
+ } else {
170
+ width = (width / height) * maxDim;
171
+ height = maxDim;
172
+ }
173
+ }
174
+
175
+ canvas.width = width;
176
+ canvas.height = height;
177
+ ctx.drawImage(img, 0, 0, width, height);
178
+
179
+ canvas.toBlob(
180
+ (blob) => {
181
+ if (blob) {
182
+ resolve(new File([blob], file.name, { type: 'image/jpeg' }));
183
+ } else {
184
+ resolve(file);
185
+ }
186
+ },
187
+ 'image/jpeg',
188
+ 0.85
189
+ );
190
+ };
191
+ img.src = e.target?.result as string;
192
+ };
193
+ reader.readAsDataURL(file);
194
+ });
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Image Upload (paste / drop)
199
+ // ---------------------------------------------------------------------------
200
+
201
+ async function uploadAndInsertImage(file: File) {
202
+ if (!editor) return;
203
+ uploadingCount++;
204
+ try {
205
+ let src: string;
206
+ if (onImageUpload) {
207
+ const compressed = await compressImage(file);
208
+ src = await onImageUpload(compressed);
209
+ } else {
210
+ // Fallback: inline data URL (no server upload)
211
+ src = await fileToDataUrl(file);
212
+ }
213
+ editor.chain().focus().setImage({ src }).run();
214
+ } finally {
215
+ uploadingCount--;
216
+ }
217
+ }
218
+
219
+ function handleFiles(files: File[]) {
220
+ for (const file of files) {
221
+ if (file.type.startsWith('image/')) {
222
+ uploadAndInsertImage(file);
223
+ }
224
+ }
225
+ }
226
+
227
+ const CustomYoutube = Node.create({
228
+ name: 'youtube',
229
+ group: 'block',
230
+ atom: true,
231
+ addAttributes() {
232
+ return {
233
+ src: { default: null },
234
+ };
235
+ },
236
+ parseHTML() {
237
+ return [{ tag: 'iframe[src*="youtube"]' }];
238
+ },
239
+ renderHTML({ HTMLAttributes }) {
240
+ return [
241
+ 'div',
242
+ { class: 'video-container my-4 aspect-video rounded-xl overflow-hidden' },
243
+ [
244
+ 'iframe',
245
+ mergeAttributes(HTMLAttributes, {
246
+ class: 'w-full h-full border-0',
247
+ allow:
248
+ 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
249
+ allowfullscreen: 'true',
250
+ }),
251
+ ],
252
+ ];
253
+ },
254
+ });
255
+ /** SubPage block extension (Page-in-page card) */
256
+ const SubPage = Node.create({
257
+ name: 'subpage',
258
+ group: 'block',
259
+ atom: true,
260
+ addAttributes() {
261
+ return {
262
+ id: { default: () => `page-${Date.now()}` },
263
+ title: { default: 'Untitled Sub-page' },
264
+ icon: { default: '📄' }
265
+ };
266
+ },
267
+ parseHTML() {
268
+ return [{ tag: 'div[data-type="subpage"]' }];
269
+ },
270
+ renderHTML({ HTMLAttributes }) {
271
+ return [
272
+ 'div',
273
+ mergeAttributes(HTMLAttributes, {
274
+ 'data-type': 'subpage',
275
+ class: 'subpage-block my-2 flex items-center gap-2.5 px-3 py-2 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-card)] hover:bg-[var(--ui-secondary)]/50 transition-colors cursor-pointer shadow-xs group',
276
+ 'data-page-id': HTMLAttributes.id,
277
+ 'data-page-title': HTMLAttributes.title
278
+ }),
279
+ ['span', { class: 'text-base shrink-0 select-none' }, HTMLAttributes.icon || '📄'],
280
+ ['span', { class: 'text-sm font-semibold text-[var(--ui-foreground)] underline-offset-4 group-hover:underline truncate' }, HTMLAttributes.title || 'Untitled Sub-page']
281
+ ];
282
+ },
283
+ });
284
+
285
+ /** Callout block extension with type icon & colored border */
286
+ const Callout = Node.create({
287
+ name: 'callout',
288
+ group: 'block',
289
+ content: 'block+',
290
+ defining: true,
291
+ addAttributes() {
292
+ return {
293
+ type: { default: 'info' }, // info | warning | tip
294
+ };
295
+ },
296
+ parseHTML() {
297
+ return [{ tag: 'div[data-type="callout"]' }];
298
+ },
299
+ renderHTML({ HTMLAttributes }) {
300
+ return [
301
+ 'div',
302
+ mergeAttributes(HTMLAttributes, {
303
+ 'data-type': 'callout',
304
+ class: `callout-box callout-${HTMLAttributes.type || 'info'} my-3 p-3.5 rounded-xl border flex gap-3`,
305
+ }),
306
+ 0,
307
+ ];
308
+ },
309
+ });
310
+
311
+ function addYoutube() {
312
+ const url = window.prompt('Enter YouTube URL:');
313
+ if (!url || !editor) return;
314
+ const match = url.match(
315
+ /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=|shorts\/)([^#&?]*).*/
316
+ );
317
+ const id = match && match[2].length === 11 ? match[2] : null;
318
+ if (id) {
319
+ editor
320
+ .chain()
321
+ .focus()
322
+ .insertContent({
323
+ type: 'youtube',
324
+ attrs: { src: `https://www.youtube.com/embed/${id}` },
325
+ })
326
+ .run();
327
+ }
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Toolbar Helpers
332
+ // ---------------------------------------------------------------------------
333
+
334
+ function getActiveStates(e: Editor): Record<string, boolean> {
335
+ return {
336
+ bold: e.isActive('bold'),
337
+ italic: e.isActive('italic'),
338
+ underline: e.isActive('underline'),
339
+ strike: e.isActive('strike'),
340
+ highlight: e.isActive('highlight'),
341
+ h1: e.isActive('heading', { level: 1 }),
342
+ h2: e.isActive('heading', { level: 2 }),
343
+ h3: e.isActive('heading', { level: 3 }),
344
+ bulletList: e.isActive('bulletList'),
345
+ orderedList: e.isActive('orderedList'),
346
+ blockquote: e.isActive('blockquote'),
347
+ codeBlock: e.isActive('codeBlock'),
348
+ alignLeft: e.isActive({ textAlign: 'left' }),
349
+ alignCenter: e.isActive({ textAlign: 'center' }),
350
+ alignRight: e.isActive({ textAlign: 'right' }),
351
+ alignJustify: e.isActive({ textAlign: 'justify' }),
352
+ link: e.isActive('link'),
353
+ };
354
+ }
355
+
356
+ function toggleBold() { editor?.chain().focus().toggleBold().run(); }
357
+ function toggleItalic() { editor?.chain().focus().toggleItalic().run(); }
358
+ function toggleUnderline() { editor?.chain().focus().toggleUnderline().run(); }
359
+ function toggleStrike() { editor?.chain().focus().toggleStrike().run(); }
360
+ function setHeading(level: 1 | 2 | 3) { editor?.chain().focus().toggleHeading({ level }).run(); }
361
+ function toggleBulletList() { editor?.chain().focus().toggleBulletList().run(); }
362
+ function toggleOrderedList() { editor?.chain().focus().toggleOrderedList().run(); }
363
+ function toggleBlockquote() { editor?.chain().focus().toggleBlockquote().run(); }
364
+ function toggleCodeBlock() { editor?.chain().focus().toggleCodeBlock().run(); }
365
+ function setAlign(align: 'left' | 'center' | 'right' | 'justify') {
366
+ editor?.chain().focus().setTextAlign(align).run();
367
+ }
368
+ function setLink() {
369
+ const url = window.prompt('Enter URL:');
370
+ if (url) editor?.chain().focus().setLink({ href: url }).run();
371
+ }
372
+ function setImage() {
373
+ const url = window.prompt('Enter image URL:');
374
+ if (url) editor?.chain().focus().setImage({ src: url }).run();
375
+ }
376
+ function insertHorizontalRule() { editor?.chain().focus().setHorizontalRule().run(); }
377
+ function insertTable() {
378
+ editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
379
+ }
380
+ function insertCallout(type: 'info' | 'warning' | 'tip') {
381
+ editor?.chain().focus().insertContent({
382
+ type: 'callout',
383
+ attrs: { type },
384
+ content: [{ type: 'paragraph', text: 'Tulis catatan penting di sini...' }]
385
+ }).run();
386
+ }
387
+ function insertSubPage() {
388
+ const title = window.prompt('Enter Sub-page title:') || 'Untitled Sub-page';
389
+ editor?.chain().focus().insertContent({
390
+ type: 'subpage',
391
+ attrs: { id: `page-${Date.now()}`, title, icon: '📄' }
392
+ }).run();
393
+ }
394
+
395
+ function executeSlashCommand(cmd: typeof slashCommands[0]) {
396
+ if (!editor) return;
397
+ const { from } = editor.state.selection;
398
+ const deleteFrom = from - (slashSearch.length + 1);
399
+ editor.chain().focus().deleteRange({ from: Math.max(0, deleteFrom), to: from }).run();
400
+ cmd.action();
401
+ showSlashMenu = false;
402
+ }
403
+ function addColumnBefore() { editor?.chain().focus().addColumnBefore().run(); }
404
+ function addColumnAfter() { editor?.chain().focus().addColumnAfter().run(); }
405
+ function deleteColumn() { editor?.chain().focus().deleteColumn().run(); }
406
+ function addRowBefore() { editor?.chain().focus().addRowBefore().run(); }
407
+ function addRowAfter() { editor?.chain().focus().addRowAfter().run(); }
408
+ function deleteRow() { editor?.chain().focus().deleteRow().run(); }
409
+ function deleteTable() { editor?.chain().focus().deleteTable().run(); }
410
+ function undo() { editor?.chain().focus().undo().run(); }
411
+ function redo() { editor?.chain().focus().redo().run(); }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Lifecycle
415
+ // ---------------------------------------------------------------------------
416
+
417
+ onMount(() => {
418
+ if (!editorEl) return;
419
+
420
+ editor = new Editor({
421
+ element: editorEl,
422
+ extensions: [
423
+ StarterKit,
424
+ Placeholder.configure({ placeholder }),
425
+ Underline,
426
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
427
+ Link.configure({ openOnClick: false }),
428
+ CustomImage.configure({
429
+ inline: false,
430
+ HTMLAttributes: {
431
+ class: 'rounded-lg mx-auto block max-w-full h-auto cursor-pointer',
432
+ },
433
+ }),
434
+ Highlight.configure({ multicolor: true }),
435
+ Typography,
436
+ Table.configure({ resizable: true }),
437
+ TableRow,
438
+ TableCell,
439
+ TableHeader,
440
+ BubbleMenu.configure({
441
+ element: bubbleMenuEl!,
442
+ pluginKey: 'bubbleMenu',
443
+ shouldShow: ({ editor: e }) =>
444
+ e.isEditable &&
445
+ !e.isActive('table') &&
446
+ e.view.state.selection.content().size > 0,
447
+ }),
448
+ CustomYoutube,
449
+ Callout,
450
+ SubPage,
451
+ TaskList,
452
+ TaskItem.configure({ nested: true }),
453
+ ],
454
+ content,
455
+ editable,
456
+ onUpdate: ({ editor: e }) => {
457
+ const html = e.getHTML();
458
+ isActive = getActiveStates(e);
459
+ onUpdate?.(html);
460
+
461
+ // Detect slash command trigger
462
+ const { state } = e;
463
+ const { from } = state.selection;
464
+ const textBefore = state.doc.textBetween(Math.max(0, from - 20), from, '\n', '\0');
465
+ const slashMatch = textBefore.match(/\/([a-zA-Z0-9]*)$/);
466
+
467
+ if (slashMatch) {
468
+ slashSearch = slashMatch[1];
469
+ slashIndex = 0;
470
+ try {
471
+ const coords = e.view.coordsAtPos(from);
472
+ const parentRect = editorEl?.getBoundingClientRect();
473
+ if (parentRect) {
474
+ slashMenuPos = {
475
+ top: coords.bottom - parentRect.top + 8,
476
+ left: Math.max(16, Math.min(coords.left - parentRect.left, parentRect.width - 260))
477
+ };
478
+ }
479
+ } catch {
480
+ slashMenuPos = { top: 60, left: 24 };
481
+ }
482
+ showSlashMenu = true;
483
+ } else {
484
+ showSlashMenu = false;
485
+ }
486
+ },
487
+ onSelectionUpdate: ({ editor: e }) => {
488
+ isActive = getActiveStates(e);
489
+ },
490
+ editorProps: {
491
+ attributes: {
492
+ class: 'prose prose-sm max-w-none focus:outline-none',
493
+ },
494
+ handleDOMEvents: {
495
+ paste: (_view, event) => {
496
+ const items = event.clipboardData?.items;
497
+ if (!items) return false;
498
+ const imageFiles: File[] = [];
499
+ for (let i = 0; i < items.length; i++) {
500
+ if (items[i].type.startsWith('image/')) {
501
+ const file = items[i].getAsFile();
502
+ if (file) imageFiles.push(file);
503
+ }
504
+ }
505
+ if (imageFiles.length) {
506
+ event.preventDefault();
507
+ handleFiles(imageFiles);
508
+ return true;
509
+ }
510
+ return false;
511
+ },
512
+ drop: (_view, event) => {
513
+ const files = event.dataTransfer?.files;
514
+ if (!files?.length) return false;
515
+ const imageFiles = Array.from(files).filter((f) =>
516
+ f.type.startsWith('image/')
517
+ );
518
+ if (imageFiles.length) {
519
+ event.preventDefault();
520
+ handleFiles(imageFiles);
521
+ return true;
522
+ }
523
+ return false;
524
+ },
525
+ click: (_view, event) => {
526
+ const target = (event.target as HTMLElement)?.closest('.subpage-block') as HTMLElement | null;
527
+ if (target) {
528
+ const id = target.getAttribute('data-page-id') || '';
529
+ const title = target.getAttribute('data-page-title') || '';
530
+ onOpenSubPage?.({ id, title });
531
+ return true;
532
+ }
533
+ return false;
534
+ },
535
+ keydown: (_view, event) => {
536
+ if (showSlashMenu) {
537
+ if (event.key === 'ArrowDown') {
538
+ event.preventDefault();
539
+ slashIndex = (slashIndex + 1) % slashFilteredCommands.length;
540
+ return true;
541
+ }
542
+ if (event.key === 'ArrowUp') {
543
+ event.preventDefault();
544
+ slashIndex = (slashIndex - 1 + slashFilteredCommands.length) % slashFilteredCommands.length;
545
+ return true;
546
+ }
547
+ if (event.key === 'Enter') {
548
+ event.preventDefault();
549
+ const cmd = slashFilteredCommands[slashIndex];
550
+ if (cmd) executeSlashCommand(cmd);
551
+ return true;
552
+ }
553
+ if (event.key === 'Escape') {
554
+ event.preventDefault();
555
+ showSlashMenu = false;
556
+ return true;
557
+ }
558
+ }
559
+ return false;
560
+ }
561
+ },
562
+ },
563
+ });
564
+ });
565
+
566
+ onDestroy(() => {
567
+ if (editor) editor.destroy();
568
+ });
569
+ </script>
570
+
571
+ <div
572
+ class={cn(
573
+ 'rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] shadow-xs overflow-hidden transition-colors',
574
+ className
575
+ )}
576
+ >
577
+ <!-- Fixed Top Toolbar (only shown in 'classic' mode) -->
578
+ {#if editable && mode === 'classic'}
579
+ <div
580
+ class="flex flex-wrap items-center gap-1 px-3 py-2 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/25 backdrop-blur-xs"
581
+ >
582
+ <div class="flex items-center gap-0.5">
583
+ <Button
584
+ variant="ghost"
585
+ size="sm"
586
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
587
+ onclick={undo}
588
+ title="Undo (Ctrl+Z)"
589
+ >
590
+ <Undo class="size-3.5" />
591
+ </Button>
592
+ <Button
593
+ variant="ghost"
594
+ size="sm"
595
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
596
+ onclick={redo}
597
+ title="Redo (Ctrl+Y)"
598
+ >
599
+ <Redo class="size-3.5" />
600
+ </Button>
601
+ </div>
602
+
603
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
604
+
605
+ <div class="flex items-center gap-0.5">
606
+ <Button
607
+ variant="ghost"
608
+ size="sm"
609
+ class={cn(
610
+ 'size-7.5 p-0 rounded-md transition-colors',
611
+ isActive.h1
612
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
613
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
614
+ )}
615
+ onclick={() => setHeading(1)}
616
+ title="Heading 1"
617
+ >
618
+ <Heading1 class="size-3.5" />
619
+ </Button>
620
+ <Button
621
+ variant="ghost"
622
+ size="sm"
623
+ class={cn(
624
+ 'size-7.5 p-0 rounded-md transition-colors',
625
+ isActive.h2
626
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
627
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
628
+ )}
629
+ onclick={() => setHeading(2)}
630
+ title="Heading 2"
631
+ >
632
+ <Heading2 class="size-3.5" />
633
+ </Button>
634
+ <Button
635
+ variant="ghost"
636
+ size="sm"
637
+ class={cn(
638
+ 'size-7.5 p-0 rounded-md transition-colors',
639
+ isActive.h3
640
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
641
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
642
+ )}
643
+ onclick={() => setHeading(3)}
644
+ title="Heading 3"
645
+ >
646
+ <Heading3 class="size-3.5" />
647
+ </Button>
648
+ </div>
649
+
650
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
651
+
652
+ <div class="flex items-center gap-0.5">
653
+ <Button
654
+ variant="ghost"
655
+ size="sm"
656
+ class={cn(
657
+ 'size-7.5 p-0 rounded-md transition-colors',
658
+ isActive.bold
659
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
660
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
661
+ )}
662
+ onclick={toggleBold}
663
+ title="Bold (Ctrl+B)"
664
+ >
665
+ <Bold class="size-3.5" />
666
+ </Button>
667
+ <Button
668
+ variant="ghost"
669
+ size="sm"
670
+ class={cn(
671
+ 'size-7.5 p-0 rounded-md transition-colors',
672
+ isActive.italic
673
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
674
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
675
+ )}
676
+ onclick={toggleItalic}
677
+ title="Italic (Ctrl+I)"
678
+ >
679
+ <Italic class="size-3.5" />
680
+ </Button>
681
+ <Button
682
+ variant="ghost"
683
+ size="sm"
684
+ class={cn(
685
+ 'size-7.5 p-0 rounded-md transition-colors',
686
+ isActive.underline
687
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
688
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
689
+ )}
690
+ onclick={toggleUnderline}
691
+ title="Underline (Ctrl+U)"
692
+ >
693
+ <UnderlineIcon class="size-3.5" />
694
+ </Button>
695
+ <Button
696
+ variant="ghost"
697
+ size="sm"
698
+ class={cn(
699
+ 'size-7.5 p-0 rounded-md transition-colors',
700
+ isActive.strike
701
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
702
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
703
+ )}
704
+ onclick={toggleStrike}
705
+ title="Strikethrough"
706
+ >
707
+ <Strikethrough class="size-3.5" />
708
+ </Button>
709
+ <Button
710
+ variant="ghost"
711
+ size="sm"
712
+ class={cn(
713
+ 'size-7.5 p-0 rounded-md transition-colors',
714
+ isActive.highlight
715
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
716
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
717
+ )}
718
+ onclick={() => toggleHighlight('#fef08a')}
719
+ title="Highlight text"
720
+ >
721
+ <Highlighter class="size-3.5" />
722
+ </Button>
723
+ </div>
724
+
725
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
726
+ <div class="flex items-center gap-0.5">
727
+ <Button
728
+ variant="ghost"
729
+ size="sm"
730
+ class={cn(
731
+ 'size-7.5 p-0 rounded-md transition-colors',
732
+ isActive.bulletList
733
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
734
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
735
+ )}
736
+ onclick={toggleBulletList}
737
+ title="Bullet list"
738
+ >
739
+ <List class="size-3.5" />
740
+ </Button>
741
+ <Button
742
+ variant="ghost"
743
+ size="sm"
744
+ class={cn(
745
+ 'size-7.5 p-0 rounded-md transition-colors',
746
+ isActive.orderedList
747
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
748
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
749
+ )}
750
+ onclick={toggleOrderedList}
751
+ title="Numbered list"
752
+ >
753
+ <ListOrdered class="size-3.5" />
754
+ </Button>
755
+ <Button
756
+ variant="ghost"
757
+ size="sm"
758
+ class={cn(
759
+ 'size-7.5 p-0 rounded-md transition-colors',
760
+ isActive.blockquote
761
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
762
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
763
+ )}
764
+ onclick={toggleBlockquote}
765
+ title="Quote"
766
+ >
767
+ <Quote class="size-3.5" />
768
+ </Button>
769
+ <Button
770
+ variant="ghost"
771
+ size="sm"
772
+ class={cn(
773
+ 'size-7.5 p-0 rounded-md transition-colors',
774
+ isActive.codeBlock
775
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
776
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
777
+ )}
778
+ onclick={toggleCodeBlock}
779
+ title="Code block"
780
+ >
781
+ <Code class="size-3.5" />
782
+ </Button>
783
+ </div>
784
+
785
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
786
+
787
+ <div class="flex items-center gap-0.5">
788
+ <Button
789
+ variant="ghost"
790
+ size="sm"
791
+ class={cn(
792
+ 'size-7.5 p-0 rounded-md transition-colors',
793
+ isActive.alignLeft
794
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
795
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
796
+ )}
797
+ onclick={() => setAlign('left')}
798
+ title="Align left"
799
+ >
800
+ <AlignLeft class="size-3.5" />
801
+ </Button>
802
+ <Button
803
+ variant="ghost"
804
+ size="sm"
805
+ class={cn(
806
+ 'size-7.5 p-0 rounded-md transition-colors',
807
+ isActive.alignCenter
808
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
809
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
810
+ )}
811
+ onclick={() => setAlign('center')}
812
+ title="Align center"
813
+ >
814
+ <AlignCenter class="size-3.5" />
815
+ </Button>
816
+ <Button
817
+ variant="ghost"
818
+ size="sm"
819
+ class={cn(
820
+ 'size-7.5 p-0 rounded-md transition-colors',
821
+ isActive.alignRight
822
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
823
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
824
+ )}
825
+ onclick={() => setAlign('right')}
826
+ title="Align right"
827
+ >
828
+ <AlignRight class="size-3.5" />
829
+ </Button>
830
+ <Button
831
+ variant="ghost"
832
+ size="sm"
833
+ class={cn(
834
+ 'size-7.5 p-0 rounded-md transition-colors',
835
+ isActive.alignJustify
836
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
837
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
838
+ )}
839
+ onclick={() => setAlign('justify')}
840
+ title="Align justify"
841
+ >
842
+ <AlignJustify class="size-3.5" />
843
+ </Button>
844
+ </div>
845
+
846
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
847
+
848
+ <div class="flex items-center gap-0.5">
849
+ <Button
850
+ variant="ghost"
851
+ size="sm"
852
+ class={cn(
853
+ 'size-7.5 p-0 rounded-md transition-colors',
854
+ isActive.link
855
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
856
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
857
+ )}
858
+ onclick={setLink}
859
+ title="Insert link"
860
+ >
861
+ <LinkIcon class="size-3.5" />
862
+ </Button>
863
+ <Button
864
+ variant="ghost"
865
+ size="sm"
866
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
867
+ onclick={setImage}
868
+ title="Insert image"
869
+ >
870
+ <ImageIcon class="size-3.5" />
871
+ </Button>
872
+ <Button
873
+ variant="ghost"
874
+ size="sm"
875
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
876
+ onclick={insertTable}
877
+ title="Insert table"
878
+ >
879
+ <TableIcon class="size-3.5" />
880
+ </Button>
881
+ <Button
882
+ variant="ghost"
883
+ size="sm"
884
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
885
+ onclick={addYoutube}
886
+ title="Embed YouTube video"
887
+ >
888
+ <Video class="size-3.5" />
889
+ </Button>
890
+ <Button
891
+ variant="ghost"
892
+ size="sm"
893
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
894
+ onclick={insertHorizontalRule}
895
+ title="Divider line"
896
+ >
897
+ <Minus class="size-3.5" />
898
+ </Button>
899
+ </div>
900
+ {#if uploadingCount > 0}
901
+ <span class="ml-auto text-xs text-[var(--ui-muted-foreground)] font-medium">
902
+ Uploading {uploadingCount} image{uploadingCount > 1 ? 's' : ''}...
903
+ </span>
904
+ {/if}
905
+ </div>
906
+ {/if}
907
+
908
+ <!-- Editor -->
909
+ <div
910
+ bind:this={editorEl}
911
+ class="prose prose-sm max-w-none p-4 focus:outline-none"
912
+ style="min-height: {height}px;"
913
+ ></div>
914
+
915
+ <!-- Slash Command Popover Menu -->
916
+ {#if showSlashMenu && slashFilteredCommands.length > 0}
917
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
918
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
919
+ <div
920
+ class="absolute z-50 w-64 max-h-72 overflow-y-auto rounded-xl border border-[var(--ui-border)] bg-[var(--ui-popover)] p-1.5 shadow-2xl text-[var(--ui-popover-foreground)] animate-in fade-in-50 zoom-in-95"
921
+ style="top: {slashMenuPos.top}px; left: {slashMenuPos.left}px;"
922
+ >
923
+ <div class="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-[var(--ui-muted-foreground)]">
924
+ Basic blocks
925
+ </div>
926
+ {#each slashFilteredCommands as cmd, i (cmd.title)}
927
+ {@const Icon = cmd.icon}
928
+ <button
929
+ type="button"
930
+ class={cn(
931
+ 'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left transition-colors cursor-pointer',
932
+ i === slashIndex ? 'bg-[var(--ui-accent)] text-[var(--ui-accent-foreground)]' : 'hover:bg-[var(--ui-secondary)]'
933
+ )}
934
+ onmouseenter={() => (slashIndex = i)}
935
+ onclick={() => executeSlashCommand(cmd)}
936
+ >
937
+ <div class="flex size-7 items-center justify-center rounded-md border border-[var(--ui-border)] bg-[var(--ui-card)] shrink-0">
938
+ <Icon class="size-4 text-[var(--ui-foreground)]" />
939
+ </div>
940
+ <div class="flex flex-col min-w-0">
941
+ <span class="text-xs font-semibold text-[var(--ui-foreground)]">{cmd.title}</span>
942
+ <span class="text-[10px] text-[var(--ui-muted-foreground)] truncate">{cmd.desc}</span>
943
+ </div>
944
+ </button>
945
+ {/each}
946
+ </div>
947
+ {/if}
948
+
949
+ <!-- BubbleMenu: floating toolbar on text selection (element bound for TipTap) -->
950
+ <div
951
+ bind:this={bubbleMenuEl}
952
+ class="flex items-center gap-1 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)]/90 px-1.5 py-1 shadow-xl backdrop-blur-md animate-in fade-in-50 zoom-in-95"
953
+ >
954
+ {#if editor}
955
+ <Button
956
+ variant="ghost"
957
+ size="sm"
958
+ class={cn(
959
+ 'size-7 p-0 rounded-md transition-colors',
960
+ editor.isActive('bold')
961
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
962
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
963
+ )}
964
+ onclick={toggleBold}
965
+ >
966
+ <Bold class="size-3.5" />
967
+ </Button>
968
+ <Button
969
+ variant="ghost"
970
+ size="sm"
971
+ class={cn(
972
+ 'size-7 p-0 rounded-md transition-colors',
973
+ editor.isActive('italic')
974
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
975
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
976
+ )}
977
+ onclick={toggleItalic}
978
+ >
979
+ <Italic class="size-3.5" />
980
+ </Button>
981
+ <Button
982
+ variant="ghost"
983
+ size="sm"
984
+ class={cn(
985
+ 'size-7 p-0 rounded-md transition-colors',
986
+ editor.isActive('underline')
987
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
988
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
989
+ )}
990
+ onclick={toggleUnderline}
991
+ >
992
+ <UnderlineIcon class="size-3.5" />
993
+ </Button>
994
+ <Button
995
+ variant="ghost"
996
+ size="sm"
997
+ class={cn(
998
+ 'size-7 p-0 rounded-md transition-colors',
999
+ editor.isActive('strike')
1000
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
1001
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
1002
+ )}
1003
+ onclick={toggleStrike}
1004
+ >
1005
+ <Strikethrough class="size-3.5" />
1006
+ </Button>
1007
+ <Button
1008
+ variant="ghost"
1009
+ size="sm"
1010
+ class={cn(
1011
+ 'size-7 p-0 rounded-md transition-colors',
1012
+ editor.isActive('highlight')
1013
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
1014
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
1015
+ )}
1016
+ onclick={() => toggleHighlight('#fef08a')}
1017
+ >
1018
+ <Highlighter class="size-3.5" />
1019
+ </Button>
1020
+ <Button
1021
+ variant="ghost"
1022
+ size="sm"
1023
+ class={cn(
1024
+ 'size-7 p-0 rounded-md transition-colors',
1025
+ editor.isActive('link')
1026
+ ? 'bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs'
1027
+ : 'text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]'
1028
+ )}
1029
+ onclick={setLink}
1030
+ >
1031
+ <LinkIcon class="size-3.5" />
1032
+ </Button>
1033
+ {/if}
1034
+ </div>
1035
+ </div>
1036
+
1037
+ <style>
1038
+ :global(.tiptap) {
1039
+ outline: none;
1040
+ }
1041
+ :global(.tiptap p.is-editor-empty:first-child::before) {
1042
+ content: attr(data-placeholder);
1043
+ float: left;
1044
+ color: var(--ui-muted-foreground);
1045
+ pointer-events: none;
1046
+ height: 0;
1047
+ }
1048
+ :global(.tiptap h1) {
1049
+ font-size: 1.5rem;
1050
+ font-weight: 700;
1051
+ margin: 0.5rem 0;
1052
+ }
1053
+ :global(.tiptap h2) {
1054
+ font-size: 1.25rem;
1055
+ font-weight: 600;
1056
+ margin: 0.5rem 0;
1057
+ }
1058
+ :global(.tiptap h3) {
1059
+ font-size: 1.125rem;
1060
+ font-weight: 600;
1061
+ margin: 0.5rem 0;
1062
+ }
1063
+ :global(.tiptap ul) {
1064
+ list-style-type: disc;
1065
+ padding-left: 1.5rem;
1066
+ }
1067
+ :global(.tiptap ol) {
1068
+ list-style-type: decimal;
1069
+ padding-left: 1.5rem;
1070
+ }
1071
+ :global(.tiptap blockquote) {
1072
+ border-left: 3px solid var(--ui-primary);
1073
+ padding-left: 1rem;
1074
+ margin-left: 0;
1075
+ color: var(--ui-muted-foreground);
1076
+ }
1077
+ /* Task List / Checklists */
1078
+ :global(.tiptap ul[data-type="taskList"]) {
1079
+ list-style: none;
1080
+ padding: 0;
1081
+ }
1082
+ :global(.tiptap ul[data-type="taskList"] li) {
1083
+ display: flex;
1084
+ align-items: flex-start;
1085
+ gap: 0.5rem;
1086
+ margin: 0.25rem 0;
1087
+ }
1088
+ :global(.tiptap ul[data-type="taskList"] li > label) {
1089
+ user-select: none;
1090
+ margin-top: 0.2rem;
1091
+ }
1092
+ :global(.tiptap ul[data-type="taskList"] li > label input[type="checkbox"]) {
1093
+ cursor: pointer;
1094
+ accent-color: var(--ui-primary);
1095
+ width: 1rem;
1096
+ height: 1rem;
1097
+ border-radius: 0.25rem;
1098
+ }
1099
+ :global(.tiptap ul[data-type="taskList"] li[data-checked="true"] > div) {
1100
+ text-decoration: line-through;
1101
+ color: var(--ui-muted-foreground);
1102
+ }
1103
+
1104
+ /* Callout Boxes */
1105
+ :global(.tiptap .callout-box) {
1106
+ background: var(--ui-secondary);
1107
+ border-radius: 0.75rem;
1108
+ padding: 0.875rem 1rem;
1109
+ border: 1px solid var(--ui-border);
1110
+ margin: 0.75rem 0;
1111
+ display: flex;
1112
+ gap: 0.75rem;
1113
+ }
1114
+ :global(.tiptap .callout-info) {
1115
+ background: color-mix(in oklch, var(--ui-info) 8%, transparent);
1116
+ border-color: color-mix(in oklch, var(--ui-info) 30%, transparent);
1117
+ }
1118
+ :global(.tiptap .callout-warning) {
1119
+ background: color-mix(in oklch, var(--ui-warning) 8%, transparent);
1120
+ border-color: color-mix(in oklch, var(--ui-warning) 30%, transparent);
1121
+ }
1122
+ :global(.tiptap .callout-tip) {
1123
+ background: color-mix(in oklch, var(--ui-success) 8%, transparent);
1124
+ border-color: color-mix(in oklch, var(--ui-success) 30%, transparent);
1125
+ }
1126
+ :global(.tiptap pre) {
1127
+ background: var(--ui-secondary);
1128
+ border-radius: 0.5rem;
1129
+ padding: 0.75rem 1rem;
1130
+ font-family: monospace;
1131
+ font-size: 0.875rem;
1132
+ }
1133
+ :global(.tiptap code) {
1134
+ background: var(--ui-secondary);
1135
+ border-radius: 0.25rem;
1136
+ padding: 0.125rem 0.25rem;
1137
+ font-size: 0.875em;
1138
+ }
1139
+ :global(.tiptap pre code) {
1140
+ background: none;
1141
+ padding: 0;
1142
+ }
1143
+ :global(.tiptap img) {
1144
+ max-width: 100%;
1145
+ border-radius: 0.5rem;
1146
+ }
1147
+ :global(.tiptap a.image-link) {
1148
+ display: block;
1149
+ text-align: center;
1150
+ }
1151
+ :global(.tiptap a.image-link img) {
1152
+ display: block;
1153
+ margin: 0 auto;
1154
+ }
1155
+ :global(.tiptap hr) {
1156
+ border: none;
1157
+ border-top: 1px solid var(--ui-border);
1158
+ margin: 1rem 0;
1159
+ }
1160
+ :global(.tiptap table) {
1161
+ border-collapse: collapse;
1162
+ width: 100%;
1163
+ margin: 1em 0;
1164
+ overflow: hidden;
1165
+ }
1166
+ :global(.tiptap th),
1167
+ :global(.tiptap td) {
1168
+ border: 1px solid var(--ui-border);
1169
+ padding: 0.5rem 0.75rem;
1170
+ text-align: left;
1171
+ position: relative;
1172
+ min-width: 80px;
1173
+ }
1174
+ :global(.tiptap th) {
1175
+ background: var(--ui-secondary);
1176
+ font-weight: 600;
1177
+ }
1178
+ :global(.tiptap td.selectedCell) {
1179
+ background: color-mix(in srgb, var(--ui-primary) 10%, transparent);
1180
+ }
1181
+ :global(.tiptap .selectedCell::after) {
1182
+ content: '';
1183
+ position: absolute;
1184
+ inset: 0;
1185
+ background: rgba(0, 0, 0, 0.05);
1186
+ pointer-events: none;
1187
+ }
1188
+ :global(.tiptap .column-resize-handle) {
1189
+ position: absolute;
1190
+ right: -2px;
1191
+ top: 0;
1192
+ bottom: 0;
1193
+ width: 4px;
1194
+ background: var(--ui-primary);
1195
+ cursor: col-resize;
1196
+ }
1197
+
1198
+ /* YouTube / Video embed */
1199
+ :global(.tiptap .video-wrapper) {
1200
+ position: relative;
1201
+ padding-bottom: 56.25%;
1202
+ height: 0;
1203
+ overflow: hidden;
1204
+ margin: 1.5rem 0;
1205
+ border-radius: 0.75rem;
1206
+ background: var(--ui-secondary);
1207
+ }
1208
+ :global(.tiptap .video-wrapper iframe) {
1209
+ position: absolute;
1210
+ top: 0;
1211
+ left: 0;
1212
+ width: 100%;
1213
+ height: 100%;
1214
+ border: 0;
1215
+ }
1216
+
1217
+ /* Typography smart-quotes styling (cosmetic, TipTap handles conversion) */
1218
+ :global(.tiptap mark) {
1219
+ background-color: #fef08a;
1220
+ padding: 0.1em 0.2em;
1221
+ border-radius: 2px;
1222
+ }
1223
+ </style>