@intinyagroup/rich-text 0.1.1-alpha.7 → 0.1.1

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.
@@ -1,46 +1,92 @@
1
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';
2
+ import { onMount, onDestroy } from "svelte";
3
+ import { Editor, Mark, Node, mergeAttributes } from "@tiptap/core";
4
+ import StarterKit from "@tiptap/starter-kit";
5
+ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
6
+ import { common, createLowlight } from "lowlight";
7
+ import Placeholder from "@tiptap/extension-placeholder";
8
+ import Underline from "@tiptap/extension-underline";
9
+ import TextAlign from "@tiptap/extension-text-align";
10
+ import Link from "@tiptap/extension-link";
11
+ import Image from "@tiptap/extension-image";
12
+ import Highlight from "@tiptap/extension-highlight";
13
+ import { Table } from "@tiptap/extension-table";
14
+ import TableRow from "@tiptap/extension-table-row";
15
+ import TableCell from "@tiptap/extension-table-cell";
16
+ import TableHeader from "@tiptap/extension-table-header";
17
+ import Typography from "@tiptap/extension-typography";
18
+ import BubbleMenu from "@tiptap/extension-bubble-menu";
19
+ import TaskList from "@tiptap/extension-task-list";
20
+ import TaskItem from "@tiptap/extension-task-item";
17
21
  import {
18
- Bold, Italic, Underline as UnderlineIcon, Strikethrough, Highlighter,
19
- AlignLeft, AlignCenter, AlignRight, AlignJustify,
20
- List, ListOrdered, Quote, Code, Minus, Link as LinkIcon, Image as ImageIcon,
21
- Undo, Redo, Heading1, Heading2, Heading3, TableIcon, Plus, Trash2,
22
+ Bold,
23
+ Italic,
24
+ Underline as UnderlineIcon,
25
+ Strikethrough,
26
+ Highlighter,
27
+ AlignLeft,
28
+ AlignCenter,
29
+ AlignRight,
30
+ AlignJustify,
31
+ List,
32
+ ListOrdered,
33
+ Quote,
34
+ Code,
35
+ Minus,
36
+ Link as LinkIcon,
37
+ Image as ImageIcon,
38
+ Undo,
39
+ Redo,
40
+ Heading1,
41
+ Heading2,
42
+ Heading3,
43
+ TableIcon,
44
+ Plus,
45
+ Trash2,
22
46
  Video,
23
- } from 'lucide-svelte';
24
- import { Button, Separator } from '@intinyagroup/ui';
25
- import { cn } from '@intinyagroup/grid-core/utils';
47
+ CheckSquare,
48
+ Info,
49
+ AlertTriangle,
50
+ Sparkles,
51
+ HelpCircle,
52
+ FileText,
53
+ Search,
54
+ Download,
55
+ Unlink,
56
+ } from "lucide-svelte";
57
+ import { Button, Input, Separator } from "@intinyagroup/ui";
58
+ import { cn } from "@intinyagroup/grid-core/utils";
59
+
60
+ const lowlight = createLowlight(common);
26
61
 
27
62
  let {
28
- content = '',
29
- placeholder = 'Start writing...',
63
+ content = "",
64
+ placeholder = "Start writing...",
30
65
  editable = true,
66
+ mode = "classic",
31
67
  height = 400,
32
68
  class: className,
33
69
  onUpdate,
34
70
  onImageUpload,
71
+ onOpenSubPage,
72
+ onExportHtml,
73
+ onExportMarkdown,
35
74
  }: {
36
75
  content?: string;
37
76
  placeholder?: string;
38
77
  editable?: boolean;
78
+ /** Toolbar mode: 'classic' (fixed top toolbar), 'bubble' (Notion-style floating toolbar only), or 'none' */
79
+ mode?: "classic" | "bubble" | "none";
39
80
  height?: number;
40
81
  class?: string;
41
82
  onUpdate?: (html: string) => void;
42
83
  /** Called when paste/drop provides an image file. Return a URL to insert. */
43
84
  onImageUpload?: (file: File) => Promise<string>;
85
+ onOpenSubPage?: (page: { id: string; title: string }) => void;
86
+ /** Called by HTML export action with current document HTML. */
87
+ onExportHtml?: (html: string) => void;
88
+ /** Called by Markdown export action with best-effort CommonMark output. */
89
+ onExportMarkdown?: (markdown: string) => void;
44
90
  } = $props();
45
91
 
46
92
  let editorEl: HTMLDivElement | null = null;
@@ -48,7 +94,146 @@
48
94
  let editor: Editor | null = null;
49
95
  let isActive = $state<Record<string, boolean>>({});
50
96
  let uploadingCount = $state(0);
97
+ let showFindReplace = $state(false);
98
+ let findQuery = $state("");
99
+ let replaceQuery = $state("");
100
+ let findCount = $state(0);
101
+
102
+ const TextColor = Mark.create({
103
+ name: "textColor",
104
+ addAttributes() {
105
+ return { color: { default: null } };
106
+ },
107
+ parseHTML() {
108
+ return [
109
+ {
110
+ tag: "span[data-text-color]",
111
+ getAttrs: (element) => ({
112
+ color:
113
+ (element as HTMLElement).style.color ||
114
+ element.getAttribute("color") ||
115
+ null,
116
+ }),
117
+ },
118
+ ];
119
+ },
120
+ renderHTML({ HTMLAttributes }) {
121
+ const color = HTMLAttributes.color;
122
+ return [
123
+ "span",
124
+ mergeAttributes({
125
+ "data-text-color": "",
126
+ style: color ? `color: ${color}` : undefined,
127
+ }),
128
+ 0,
129
+ ];
130
+ },
131
+ });
132
+
133
+ // Slash commands state
134
+ let showSlashMenu = $state(false);
135
+ let slashSearch = $state("");
136
+ let slashIndex = $state(0);
137
+ let slashMenuPos = $state({ top: 0, left: 0 });
138
+
139
+ let textColor = $state("#1f2937");
140
+ let highlightColor = $state("#fef08a");
141
+ let searchMatches: Array<{ from: number; to: number }> = [];
51
142
 
143
+ const slashCommands = [
144
+ {
145
+ title: "Sub-page",
146
+ desc: "Embed a sub-page inside this page",
147
+ icon: FileText,
148
+ action: () => insertSubPage(),
149
+ },
150
+ {
151
+ title: "Heading 1",
152
+ desc: "Big section heading",
153
+ icon: Heading1,
154
+ action: () => setHeading(1),
155
+ },
156
+ {
157
+ title: "Heading 2",
158
+ desc: "Medium section heading",
159
+ icon: Heading2,
160
+ action: () => setHeading(2),
161
+ },
162
+ {
163
+ title: "Heading 3",
164
+ desc: "Small subsection heading",
165
+ icon: Heading3,
166
+ action: () => setHeading(3),
167
+ },
168
+ {
169
+ title: "To-do list",
170
+ desc: "Track tasks with a checklist",
171
+ icon: CheckSquare,
172
+ action: () => editor?.chain().focus().toggleTaskList().run(),
173
+ },
174
+ {
175
+ title: "Bullet list",
176
+ desc: "Create a bulleted list",
177
+ icon: List,
178
+ action: () => toggleBulletList(),
179
+ },
180
+ {
181
+ title: "Numbered list",
182
+ desc: "Create a numbered list",
183
+ icon: ListOrdered,
184
+ action: () => toggleOrderedList(),
185
+ },
186
+ {
187
+ title: "Quote",
188
+ desc: "Capture a quote or blockquote",
189
+ icon: Quote,
190
+ action: () => toggleBlockquote(),
191
+ },
192
+ {
193
+ title: "Code block",
194
+ desc: "Code snippet with syntax highlight",
195
+ icon: Code,
196
+ action: () => toggleCodeBlock(),
197
+ },
198
+ {
199
+ title: "Callout Info",
200
+ desc: "Informational highlight block",
201
+ icon: Info,
202
+ action: () => insertCallout("info"),
203
+ },
204
+ {
205
+ title: "Callout Warning",
206
+ desc: "Warning or caution block",
207
+ icon: AlertTriangle,
208
+ action: () => insertCallout("warning"),
209
+ },
210
+ {
211
+ title: "Callout Tip",
212
+ desc: "Tip or recommendation block",
213
+ icon: Sparkles,
214
+ action: () => insertCallout("tip"),
215
+ },
216
+ {
217
+ title: "Table",
218
+ desc: "Insert 3x3 table grid",
219
+ icon: TableIcon,
220
+ action: () => insertTable(),
221
+ },
222
+ {
223
+ title: "Divider",
224
+ desc: "Visually divide sections",
225
+ icon: Minus,
226
+ action: () => insertHorizontalRule(),
227
+ },
228
+ ];
229
+
230
+ const slashFilteredCommands = $derived(
231
+ slashCommands.filter(
232
+ (c) =>
233
+ c.title.toLowerCase().includes(slashSearch.toLowerCase()) ||
234
+ c.desc.toLowerCase().includes(slashSearch.toLowerCase()),
235
+ ),
236
+ );
52
237
  // ---------------------------------------------------------------------------
53
238
  // Custom Extensions
54
239
  // ---------------------------------------------------------------------------
@@ -59,16 +244,19 @@
59
244
  return {
60
245
  ...this.parent?.(),
61
246
  href: { default: null },
62
- alt: { default: '' },
63
- title: { default: '' },
64
- target: { default: '_blank' },
247
+ alt: { default: "" },
248
+ title: { default: "" },
249
+ target: { default: "_blank" },
65
250
  };
66
251
  },
67
252
  renderHTML({ HTMLAttributes }) {
68
253
  const { href, target, alt, title, ...rest } = HTMLAttributes;
69
- const img = ['img', mergeAttributes(this.options.HTMLAttributes, rest, { alt, title })];
254
+ const img = [
255
+ "img",
256
+ mergeAttributes(this.options.HTMLAttributes, rest, { alt, title }),
257
+ ];
70
258
  if (href) {
71
- return ['a', { href, target, class: 'image-link' }, img];
259
+ return ["a", { href, target, class: "image-link" }, img];
72
260
  }
73
261
  return img;
74
262
  },
@@ -76,8 +264,8 @@
76
264
 
77
265
  /** YouTube iframe embed node */
78
266
  const CustomYoutube = Node.create({
79
- name: 'youtube',
80
- group: 'block',
267
+ name: "youtube",
268
+ group: "block",
81
269
  atom: true,
82
270
  addAttributes() {
83
271
  return { src: { default: null } };
@@ -90,14 +278,14 @@
90
278
  },
91
279
  renderHTML({ HTMLAttributes }) {
92
280
  return [
93
- 'div',
94
- { class: 'video-wrapper' },
281
+ "div",
282
+ { class: "video-wrapper" },
95
283
  [
96
- 'iframe',
284
+ "iframe",
97
285
  mergeAttributes(HTMLAttributes, {
98
- width: '100%',
99
- height: '315',
100
- allowfullscreen: 'true',
286
+ width: "100%",
287
+ height: "315",
288
+ allowfullscreen: "true",
101
289
  }),
102
290
  ],
103
291
  ];
@@ -116,8 +304,8 @@
116
304
  reader.onload = (e) => {
117
305
  const img = new Image();
118
306
  img.onload = () => {
119
- const canvas = document.createElement('canvas');
120
- const ctx = canvas.getContext('2d');
307
+ const canvas = document.createElement("canvas");
308
+ const ctx = canvas.getContext("2d");
121
309
  if (!ctx) {
122
310
  resolve(file);
123
311
  return;
@@ -143,13 +331,13 @@
143
331
  canvas.toBlob(
144
332
  (blob) => {
145
333
  if (blob) {
146
- resolve(new File([blob], file.name, { type: 'image/jpeg' }));
334
+ resolve(new File([blob], file.name, { type: "image/jpeg" }));
147
335
  } else {
148
336
  resolve(file);
149
337
  }
150
338
  },
151
- 'image/jpeg',
152
- 0.85
339
+ "image/jpeg",
340
+ 0.85,
153
341
  );
154
342
  };
155
343
  img.src = e.target?.result as string;
@@ -172,34 +360,95 @@
172
360
  src = await onImageUpload(compressed);
173
361
  } else {
174
362
  // Fallback: inline data URL (no server upload)
175
- src = await new Promise<string>((resolve) => {
176
- const reader = new FileReader();
177
- reader.onload = (ev) => resolve(ev.target?.result as string);
178
- reader.readAsDataURL(file);
179
- });
363
+ src = await fileToDataUrl(file);
180
364
  }
181
365
  editor.chain().focus().setImage({ src }).run();
182
- } catch (err) {
183
- console.error('Failed to upload image:', err);
184
366
  } finally {
185
367
  uploadingCount--;
186
368
  }
187
369
  }
188
370
 
189
- async function handleFiles(files: File[]) {
190
- const images = Array.from(files).filter((f) => f.type.startsWith('image/'));
191
- await Promise.all(images.map(uploadAndInsertImage));
371
+ function handleFiles(files: File[]) {
372
+ for (const file of files) {
373
+ if (file.type.startsWith("image/")) {
374
+ uploadAndInsertImage(file);
375
+ }
376
+ }
192
377
  }
193
378
 
194
- // ---------------------------------------------------------------------------
195
- // YouTube Embed
196
- // ---------------------------------------------------------------------------
379
+ /** SubPage block extension (Page-in-page card) */
380
+ const SubPage = Node.create({
381
+ name: "subpage",
382
+ group: "block",
383
+ atom: true,
384
+ addAttributes() {
385
+ return {
386
+ id: { default: () => `page-${Date.now()}` },
387
+ title: { default: "Untitled Sub-page" },
388
+ icon: { default: "📄" },
389
+ };
390
+ },
391
+ parseHTML() {
392
+ return [{ tag: 'div[data-type="subpage"]' }];
393
+ },
394
+ renderHTML({ HTMLAttributes }) {
395
+ return [
396
+ "div",
397
+ mergeAttributes(HTMLAttributes, {
398
+ "data-type": "subpage",
399
+ class:
400
+ "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",
401
+ "data-page-id": HTMLAttributes.id,
402
+ "data-page-title": HTMLAttributes.title,
403
+ }),
404
+ [
405
+ "span",
406
+ { class: "text-base shrink-0 select-none" },
407
+ HTMLAttributes.icon || "📄",
408
+ ],
409
+ [
410
+ "span",
411
+ {
412
+ class:
413
+ "text-sm font-semibold text-[var(--ui-foreground)] underline-offset-4 group-hover:underline truncate",
414
+ },
415
+ HTMLAttributes.title || "Untitled Sub-page",
416
+ ],
417
+ ];
418
+ },
419
+ });
420
+
421
+ /** Callout block extension with type icon & colored border */
422
+ const Callout = Node.create({
423
+ name: "callout",
424
+ group: "block",
425
+ content: "block+",
426
+ defining: true,
427
+ addAttributes() {
428
+ return {
429
+ type: { default: "info" }, // info | warning | tip
430
+ };
431
+ },
432
+ parseHTML() {
433
+ return [{ tag: 'div[data-type="callout"]' }];
434
+ },
435
+ renderHTML({ HTMLAttributes }) {
436
+ return [
437
+ "div",
438
+ mergeAttributes(HTMLAttributes, {
439
+ "data-type": "callout",
440
+ class: `callout-box callout-${HTMLAttributes.type || "info"} my-3 p-3.5 rounded-xl border flex gap-3`,
441
+ }),
442
+ 0,
443
+ ];
444
+ },
445
+ });
197
446
 
198
447
  function addYoutube() {
199
- const url = window.prompt('Enter YouTube URL:');
448
+ const url = window.prompt("Enter YouTube URL:");
200
449
  if (!url || !editor) return;
201
450
  const match = url.match(
202
- /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=|shorts\/)([^#&?]*).*/
451
+ /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=|shorts\/)([^#&?]*).*/,
203
452
  );
204
453
  const id = match && match[2].length === 11 ? match[2] : null;
205
454
  if (id) {
@@ -207,7 +456,7 @@
207
456
  .chain()
208
457
  .focus()
209
458
  .insertContent({
210
- type: 'youtube',
459
+ type: "youtube",
211
460
  attrs: { src: `https://www.youtube.com/embed/${id}` },
212
461
  })
213
462
  .run();
@@ -220,66 +469,257 @@
220
469
 
221
470
  function getActiveStates(e: Editor): Record<string, boolean> {
222
471
  return {
223
- bold: e.isActive('bold'),
224
- italic: e.isActive('italic'),
225
- underline: e.isActive('underline'),
226
- strike: e.isActive('strike'),
227
- highlight: e.isActive('highlight'),
228
- h1: e.isActive('heading', { level: 1 }),
229
- h2: e.isActive('heading', { level: 2 }),
230
- h3: e.isActive('heading', { level: 3 }),
231
- bulletList: e.isActive('bulletList'),
232
- orderedList: e.isActive('orderedList'),
233
- blockquote: e.isActive('blockquote'),
234
- codeBlock: e.isActive('codeBlock'),
235
- alignLeft: e.isActive({ textAlign: 'left' }),
236
- alignCenter: e.isActive({ textAlign: 'center' }),
237
- alignRight: e.isActive({ textAlign: 'right' }),
238
- alignJustify: e.isActive({ textAlign: 'justify' }),
239
- link: e.isActive('link'),
472
+ bold: e.isActive("bold"),
473
+ italic: e.isActive("italic"),
474
+ underline: e.isActive("underline"),
475
+ strike: e.isActive("strike"),
476
+ highlight: e.isActive("highlight"),
477
+ textColor: e.isActive("textColor"),
478
+ h1: e.isActive("heading", { level: 1 }),
479
+ h2: e.isActive("heading", { level: 2 }),
480
+ h3: e.isActive("heading", { level: 3 }),
481
+ bulletList: e.isActive("bulletList"),
482
+ orderedList: e.isActive("orderedList"),
483
+ blockquote: e.isActive("blockquote"),
484
+ codeBlock: e.isActive("codeBlock"),
485
+ alignLeft: e.isActive({ textAlign: "left" }),
486
+ alignCenter: e.isActive({ textAlign: "center" }),
487
+ alignRight: e.isActive({ textAlign: "right" }),
488
+ alignJustify: e.isActive({ textAlign: "justify" }),
489
+ link: e.isActive("link"),
240
490
  };
241
491
  }
242
492
 
243
- function toggleBold() { editor?.chain().focus().toggleBold().run(); }
244
- function toggleItalic() { editor?.chain().focus().toggleItalic().run(); }
245
- function toggleUnderline() { editor?.chain().focus().toggleUnderline().run(); }
246
- function toggleStrike() { editor?.chain().focus().toggleStrike().run(); }
247
- function setHeading(level: 1 | 2 | 3) { editor?.chain().focus().toggleHeading({ level }).run(); }
248
- function toggleBulletList() { editor?.chain().focus().toggleBulletList().run(); }
249
- function toggleOrderedList() { editor?.chain().focus().toggleOrderedList().run(); }
250
- function toggleBlockquote() { editor?.chain().focus().toggleBlockquote().run(); }
251
- function toggleCodeBlock() { editor?.chain().focus().toggleCodeBlock().run(); }
252
- function setAlign(align: 'left' | 'center' | 'right' | 'justify') {
253
- editor?.chain().focus().setTextAlign(align).run();
493
+ function setTextColor(color: string) {
494
+ textColor = color;
495
+ editor?.chain().focus().setMark("textColor", { color }).run();
254
496
  }
255
- function setLink() {
256
- const url = window.prompt('Enter URL:');
257
- if (url) editor?.chain().focus().setLink({ href: url }).run();
497
+ function toggleTextColor() {
498
+ if (editor?.isActive("textColor"))
499
+ editor.chain().focus().unsetMark("textColor").run();
500
+ else setTextColor(textColor);
501
+ }
502
+ function toggleHighlight(color = highlightColor) {
503
+ highlightColor = color;
504
+ editor?.chain().focus().toggleHighlight({ color }).run();
505
+ }
506
+ function updateSearchMatches() {
507
+ searchMatches = [];
508
+ if (!editor || !findQuery) {
509
+ findCount = 0;
510
+ return;
511
+ }
512
+ const query = findQuery.toLocaleLowerCase();
513
+ editor.state.doc.descendants((node, pos) => {
514
+ if (!node.isText || !node.text) return;
515
+ let index = 0;
516
+ const text = node.text.toLocaleLowerCase();
517
+ while ((index = text.indexOf(query, index)) !== -1) {
518
+ searchMatches.push({
519
+ from: pos + 1 + index,
520
+ to: pos + 1 + index + query.length,
521
+ });
522
+ index += query.length;
523
+ }
524
+ });
525
+ findCount = searchMatches.length;
526
+ }
527
+ function findNext() {
528
+ updateSearchMatches();
529
+ if (!editor || !searchMatches.length) return;
530
+ const current = editor.state.selection.from;
531
+ const next =
532
+ searchMatches.find((match) => match.from > current) ?? searchMatches[0];
533
+ editor.chain().focus().setTextSelection(next).run();
534
+ }
535
+ function replaceCurrent() {
536
+ if (!editor || !findQuery) return;
537
+ const current = editor.state.selection;
538
+ if (
539
+ current.from !== current.to &&
540
+ editor.state.doc.textBetween(current.from, current.to) === findQuery
541
+ ) {
542
+ editor
543
+ .chain()
544
+ .focus()
545
+ .insertContentAt({ from: current.from, to: current.to }, replaceQuery)
546
+ .run();
547
+ } else findNext();
548
+ updateSearchMatches();
549
+ }
550
+ function replaceAll() {
551
+ updateSearchMatches();
552
+ if (!editor || !searchMatches.length) return;
553
+ const transaction = editor.state.tr;
554
+ for (const match of [...searchMatches].reverse())
555
+ transaction.insertText(replaceQuery, match.from, match.to);
556
+ editor.view.dispatch(transaction);
557
+ updateSearchMatches();
558
+ }
559
+ function markdownFromHtml(html: string) {
560
+ return html
561
+ .replace(/<br\s*\/?>(\n)?/gi, "\n")
562
+ .replace(
563
+ /<h([1-6])>(.*?)<\/h\1>/gi,
564
+ (_m, level, text) => `${"#".repeat(Number(level))} ${text}\n\n`,
565
+ )
566
+ .replace(/<strong>(.*?)<\/strong>/gi, "**$1**")
567
+ .replace(/<em>(.*?)<\/em>/gi, "_$1_")
568
+ .replace(/<u>(.*?)<\/u>/gi, "__$1__")
569
+ .replace(/<s>(.*?)<\/s>/gi, "~~$1~~")
570
+ .replace(/<a[^>]+href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, "[$2]($1)")
571
+ .replace(/<li>(.*?)<\/li>/gi, "- $1\n")
572
+ .replace(/<p>(.*?)<\/p>/gi, "$1\n\n")
573
+ .replace(/<hr\s*\/?>(\n)?/gi, "---\n\n")
574
+ .replace(/<[^>]+>/g, "")
575
+ .replace(/&nbsp;/g, " ")
576
+ .replace(/&amp;/g, "&")
577
+ .replace(/&lt;/g, "<")
578
+ .replace(/&gt;/g, ">")
579
+ .replace(/\n{3,}/g, "\n\n")
580
+ .trim();
581
+ }
582
+ function downloadText(filename: string, content: string, type: string) {
583
+ const url = URL.createObjectURL(new Blob([content], { type }));
584
+ const link = document.createElement("a");
585
+ link.href = url;
586
+ link.download = filename;
587
+ link.click();
588
+ URL.revokeObjectURL(url);
589
+ }
590
+ function exportHtml() {
591
+ if (!editor) return;
592
+ const html = editor.getHTML();
593
+ onExportHtml?.(html);
594
+ downloadText("document.html", html, "text/html;charset=utf-8");
595
+ }
596
+ function exportMarkdown() {
597
+ if (!editor) return;
598
+ const markdown = markdownFromHtml(editor.getHTML());
599
+ onExportMarkdown?.(markdown);
600
+ downloadText("document.md", markdown, "text/markdown;charset=utf-8");
601
+ }
602
+ function toggleBold() {
603
+ editor?.chain().focus().toggleBold().run();
604
+ }
605
+ function toggleItalic() {
606
+ editor?.chain().focus().toggleItalic().run();
607
+ }
608
+ function toggleUnderline() {
609
+ editor?.chain().focus().toggleUnderline().run();
610
+ }
611
+ function toggleStrike() {
612
+ editor?.chain().focus().toggleStrike().run();
613
+ }
614
+ function setHeading(level: 1 | 2 | 3) {
615
+ editor?.chain().focus().toggleHeading({ level }).run();
616
+ }
617
+ function toggleBulletList() {
618
+ editor?.chain().focus().toggleBulletList().run();
619
+ }
620
+ function toggleOrderedList() {
621
+ editor?.chain().focus().toggleOrderedList().run();
622
+ }
623
+ function toggleBlockquote() {
624
+ editor?.chain().focus().toggleBlockquote().run();
625
+ }
626
+ function toggleCodeBlock() {
627
+ editor?.chain().focus().toggleCodeBlock().run();
628
+ }
629
+ function setAlign(align: "left" | "center" | "right" | "justify") {
630
+ editor?.chain().focus().setTextAlign(align).run();
258
631
  }
259
632
  function setImage() {
260
- const url = window.prompt('Enter image URL:');
633
+ const url = window.prompt("Enter image URL:");
261
634
  if (url) editor?.chain().focus().setImage({ src: url }).run();
262
635
  }
263
- function insertHorizontalRule() { editor?.chain().focus().setHorizontalRule().run(); }
636
+ function insertHorizontalRule() {
637
+ editor?.chain().focus().setHorizontalRule().run();
638
+ }
264
639
  function insertTable() {
265
- editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
640
+ editor
641
+ ?.chain()
642
+ .focus()
643
+ .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
644
+ .run();
645
+ }
646
+ function insertCallout(type: "info" | "warning" | "tip") {
647
+ editor
648
+ ?.chain()
649
+ .focus()
650
+ .insertContent({
651
+ type: "callout",
652
+ attrs: { type },
653
+ content: [
654
+ { type: "paragraph", text: "Tulis catatan penting di sini..." },
655
+ ],
656
+ })
657
+ .run();
266
658
  }
267
- function toggleHighlight(color?: string) {
268
- if (color) {
269
- editor?.chain().focus().toggleHighlight({ color }).run();
270
- } else {
271
- editor?.chain().focus().toggleHighlight().run();
659
+ function insertSubPage() {
660
+ const title = window.prompt("Enter Sub-page title:") || "Untitled Sub-page";
661
+ editor
662
+ ?.chain()
663
+ .focus()
664
+ .insertContent({
665
+ type: "subpage",
666
+ attrs: { id: `page-${Date.now()}`, title, icon: "📄" },
667
+ })
668
+ .run();
669
+ }
670
+
671
+ function executeSlashCommand(cmd: (typeof slashCommands)[0]) {
672
+ if (!editor) return;
673
+ const { from } = editor.state.selection;
674
+ const deleteFrom = from - (slashSearch.length + 1);
675
+ editor
676
+ .chain()
677
+ .focus()
678
+ .deleteRange({ from: Math.max(0, deleteFrom), to: from })
679
+ .run();
680
+ cmd.action();
681
+ showSlashMenu = false;
682
+ }
683
+ function setLink() {
684
+ if (!editor) return;
685
+ if (editor.isActive("link")) {
686
+ const current = editor.getAttributes("link").href || "";
687
+ const url = window.prompt("Edit URL:", current);
688
+ if (url === null) return;
689
+ if (!url.trim()) editor.chain().focus().unsetLink().run();
690
+ else editor.chain().focus().setLink({ href: url.trim() }).run();
691
+ return;
272
692
  }
693
+ const url = window.prompt("Enter URL:");
694
+ if (url?.trim()) editor.chain().focus().setLink({ href: url.trim() }).run();
695
+ }
696
+ function addColumnBefore() {
697
+ editor?.chain().focus().addColumnBefore().run();
698
+ }
699
+ function addColumnAfter() {
700
+ editor?.chain().focus().addColumnAfter().run();
701
+ }
702
+ function deleteColumn() {
703
+ editor?.chain().focus().deleteColumn().run();
704
+ }
705
+ function addRowBefore() {
706
+ editor?.chain().focus().addRowBefore().run();
707
+ }
708
+ function addRowAfter() {
709
+ editor?.chain().focus().addRowAfter().run();
710
+ }
711
+ function deleteRow() {
712
+ editor?.chain().focus().deleteRow().run();
713
+ }
714
+ function deleteTable() {
715
+ editor?.chain().focus().deleteTable().run();
716
+ }
717
+ function undo() {
718
+ editor?.chain().focus().undo().run();
719
+ }
720
+ function redo() {
721
+ editor?.chain().focus().redo().run();
273
722
  }
274
- function addColumnBefore() { editor?.chain().focus().addColumnBefore().run(); }
275
- function addColumnAfter() { editor?.chain().focus().addColumnAfter().run(); }
276
- function deleteColumn() { editor?.chain().focus().deleteColumn().run(); }
277
- function addRowBefore() { editor?.chain().focus().addRowBefore().run(); }
278
- function addRowAfter() { editor?.chain().focus().addRowAfter().run(); }
279
- function deleteRow() { editor?.chain().focus().deleteRow().run(); }
280
- function deleteTable() { editor?.chain().focus().deleteTable().run(); }
281
- function undo() { editor?.chain().focus().undo().run(); }
282
- function redo() { editor?.chain().focus().redo().run(); }
283
723
 
284
724
  // ---------------------------------------------------------------------------
285
725
  // Lifecycle
@@ -291,15 +731,17 @@
291
731
  editor = new Editor({
292
732
  element: editorEl,
293
733
  extensions: [
294
- StarterKit,
734
+ StarterKit.configure({ codeBlock: false }),
735
+ CodeBlockLowlight.configure({ lowlight }),
295
736
  Placeholder.configure({ placeholder }),
296
737
  Underline,
297
- TextAlign.configure({ types: ['heading', 'paragraph'] }),
738
+ TextColor,
739
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
298
740
  Link.configure({ openOnClick: false }),
299
741
  CustomImage.configure({
300
742
  inline: false,
301
743
  HTMLAttributes: {
302
- class: 'rounded-lg mx-auto block max-w-full h-auto cursor-pointer',
744
+ class: "rounded-lg mx-auto block max-w-full h-auto cursor-pointer",
303
745
  },
304
746
  }),
305
747
  Highlight.configure({ multicolor: true }),
@@ -310,13 +752,17 @@
310
752
  TableHeader,
311
753
  BubbleMenu.configure({
312
754
  element: bubbleMenuEl!,
313
- pluginKey: 'bubbleMenu',
755
+ pluginKey: "bubbleMenu",
314
756
  shouldShow: ({ editor: e }) =>
315
757
  e.isEditable &&
316
- !e.isActive('table') &&
758
+ !e.isActive("table") &&
317
759
  e.view.state.selection.content().size > 0,
318
760
  }),
319
761
  CustomYoutube,
762
+ Callout,
763
+ SubPage,
764
+ TaskList,
765
+ TaskItem.configure({ nested: true }),
320
766
  ],
321
767
  content,
322
768
  editable,
@@ -324,13 +770,51 @@
324
770
  const html = e.getHTML();
325
771
  isActive = getActiveStates(e);
326
772
  onUpdate?.(html);
773
+
774
+ // Detect slash command trigger
775
+ const { state } = e;
776
+ const { from } = state.selection;
777
+ const textBefore = state.doc.textBetween(
778
+ Math.max(0, from - 20),
779
+ from,
780
+ "\n",
781
+ "\0",
782
+ );
783
+ const slashMatch = textBefore.match(/\/([a-zA-Z0-9]*)$/);
784
+
785
+ if (slashMatch) {
786
+ slashSearch = slashMatch[1];
787
+ slashIndex = 0;
788
+ try {
789
+ const coords = e.view.coordsAtPos(from);
790
+ const parentRect = editorEl?.getBoundingClientRect();
791
+ if (parentRect) {
792
+ slashMenuPos = {
793
+ top: coords.bottom - parentRect.top + 8,
794
+ left: Math.max(
795
+ 16,
796
+ Math.min(
797
+ coords.left - parentRect.left,
798
+ parentRect.width - 260,
799
+ ),
800
+ ),
801
+ };
802
+ }
803
+ } catch {
804
+ slashMenuPos = { top: 60, left: 24 };
805
+ }
806
+ showSlashMenu = true;
807
+ } else {
808
+ showSlashMenu = false;
809
+ }
327
810
  },
328
811
  onSelectionUpdate: ({ editor: e }) => {
329
812
  isActive = getActiveStates(e);
330
813
  },
331
814
  editorProps: {
332
815
  attributes: {
333
- class: 'prose prose-sm max-w-none focus:outline-none',
816
+ class:
817
+ "rich-text-content prose prose-sm max-w-none focus:outline-none",
334
818
  },
335
819
  handleDOMEvents: {
336
820
  paste: (_view, event) => {
@@ -338,7 +822,7 @@
338
822
  if (!items) return false;
339
823
  const imageFiles: File[] = [];
340
824
  for (let i = 0; i < items.length; i++) {
341
- if (items[i].type.startsWith('image/')) {
825
+ if (items[i].type.startsWith("image/")) {
342
826
  const file = items[i].getAsFile();
343
827
  if (file) imageFiles.push(file);
344
828
  }
@@ -354,7 +838,7 @@
354
838
  const files = event.dataTransfer?.files;
355
839
  if (!files?.length) return false;
356
840
  const imageFiles = Array.from(files).filter((f) =>
357
- f.type.startsWith('image/')
841
+ f.type.startsWith("image/"),
358
842
  );
359
843
  if (imageFiles.length) {
360
844
  event.preventDefault();
@@ -363,6 +847,46 @@
363
847
  }
364
848
  return false;
365
849
  },
850
+ click: (_view, event) => {
851
+ const target = (event.target as HTMLElement)?.closest(
852
+ ".subpage-block",
853
+ ) as HTMLElement | null;
854
+ if (target) {
855
+ const id = target.getAttribute("data-page-id") || "";
856
+ const title = target.getAttribute("data-page-title") || "";
857
+ onOpenSubPage?.({ id, title });
858
+ return true;
859
+ }
860
+ return false;
861
+ },
862
+ keydown: (_view, event) => {
863
+ if (showSlashMenu) {
864
+ if (event.key === "ArrowDown") {
865
+ event.preventDefault();
866
+ slashIndex = (slashIndex + 1) % slashFilteredCommands.length;
867
+ return true;
868
+ }
869
+ if (event.key === "ArrowUp") {
870
+ event.preventDefault();
871
+ slashIndex =
872
+ (slashIndex - 1 + slashFilteredCommands.length) %
873
+ slashFilteredCommands.length;
874
+ return true;
875
+ }
876
+ if (event.key === "Enter") {
877
+ event.preventDefault();
878
+ const cmd = slashFilteredCommands[slashIndex];
879
+ if (cmd) executeSlashCommand(cmd);
880
+ return true;
881
+ }
882
+ if (event.key === "Escape") {
883
+ event.preventDefault();
884
+ showSlashMenu = false;
885
+ return true;
886
+ }
887
+ }
888
+ return false;
889
+ },
366
890
  },
367
891
  },
368
892
  });
@@ -375,243 +899,519 @@
375
899
 
376
900
  <div
377
901
  class={cn(
378
- 'rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] overflow-hidden',
379
- className
902
+ "rich-text-editor group rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] shadow-sm overflow-hidden transition-[border-color,box-shadow] focus-within:border-[var(--ui-ring)] focus-within:ring-2 focus-within:ring-[var(--ui-ring)]/15",
903
+ className,
380
904
  )}
381
905
  >
382
- <!-- Toolbar -->
383
- {#if editable}
906
+ <!-- Fixed Top Toolbar (only shown in 'classic' mode) -->
907
+ {#if editable && mode === "classic"}
384
908
  <div
385
- class="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/30"
909
+ class="rich-text-toolbar sticky top-0 z-20 flex min-h-12 items-center gap-1 overflow-x-auto border-b border-[var(--ui-border)] bg-[var(--ui-card)]/95 px-2.5 py-2 backdrop-blur-md sm:px-3"
910
+ role="toolbar"
911
+ aria-label="Text formatting"
386
912
  >
387
- <Button variant="ghost" size="sm" class="size-8 p-0" onclick={undo}>
388
- <Undo class="size-4" />
389
- </Button>
390
- <Button variant="ghost" size="sm" class="size-8 p-0" onclick={redo}>
391
- <Redo class="size-4" />
392
- </Button>
393
-
394
- <Separator orientation="vertical" class="h-6 mx-1" />
395
-
396
- <Button
397
- variant="ghost"
398
- size="sm"
399
- class={cn('size-8 p-0', isActive.h1 && 'bg-[var(--ui-primary)]/10')}
400
- onclick={() => setHeading(1)}
401
- >
402
- <Heading1 class="size-4" />
403
- </Button>
404
- <Button
405
- variant="ghost"
406
- size="sm"
407
- class={cn('size-8 p-0', isActive.h2 && 'bg-[var(--ui-primary)]/10')}
408
- onclick={() => setHeading(2)}
409
- >
410
- <Heading2 class="size-4" />
411
- </Button>
412
- <Button
413
- variant="ghost"
414
- size="sm"
415
- class={cn('size-8 p-0', isActive.h3 && 'bg-[var(--ui-primary)]/10')}
416
- onclick={() => setHeading(3)}
417
- >
418
- <Heading3 class="size-4" />
419
- </Button>
913
+ <div class="flex items-center gap-0.5">
914
+ <Button
915
+ variant="ghost"
916
+ size="sm"
917
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
918
+ onclick={undo}
919
+ title="Undo (Ctrl+Z)"
920
+ >
921
+ <Undo class="size-3.5" />
922
+ </Button>
923
+ <Button
924
+ variant="ghost"
925
+ size="sm"
926
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
927
+ onclick={redo}
928
+ title="Redo (Ctrl+Y)"
929
+ >
930
+ <Redo class="size-3.5" />
931
+ </Button>
932
+ </div>
420
933
 
421
- <Separator orientation="vertical" class="h-6 mx-1" />
934
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
422
935
 
423
- <Button
424
- variant="ghost"
425
- size="sm"
426
- class={cn('size-8 p-0', isActive.bold && 'bg-[var(--ui-primary)]/10')}
427
- onclick={toggleBold}
428
- >
429
- <Bold class="size-4" />
430
- </Button>
431
- <Button
432
- variant="ghost"
433
- size="sm"
434
- class={cn('size-8 p-0', isActive.italic && 'bg-[var(--ui-primary)]/10')}
435
- onclick={toggleItalic}
436
- >
437
- <Italic class="size-4" />
438
- </Button>
439
- <Button
440
- variant="ghost"
441
- size="sm"
442
- class={cn('size-8 p-0', isActive.underline && 'bg-[var(--ui-primary)]/10')}
443
- onclick={toggleUnderline}
444
- >
445
- <UnderlineIcon class="size-4" />
446
- </Button>
447
- <Button
448
- variant="ghost"
449
- size="sm"
450
- class={cn('size-8 p-0', isActive.strike && 'bg-[var(--ui-primary)]/10')}
451
- onclick={toggleStrike}
452
- >
453
- <Strikethrough class="size-4" />
454
- </Button>
455
- <Button
456
- variant="ghost"
457
- size="sm"
458
- class={cn(
459
- 'size-8 p-0',
460
- isActive.highlight && 'bg-[var(--ui-primary)]/10'
461
- )}
462
- onclick={() => toggleHighlight('#fef08a')}
463
- >
464
- <Highlighter class="size-4" />
465
- </Button>
936
+ <div class="flex items-center gap-0.5">
937
+ <Button
938
+ variant="ghost"
939
+ size="sm"
940
+ class={cn(
941
+ "size-7.5 p-0 rounded-md transition-colors",
942
+ isActive.h1
943
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
944
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
945
+ )}
946
+ onclick={() => setHeading(1)}
947
+ title="Heading 1"
948
+ >
949
+ <Heading1 class="size-3.5" />
950
+ </Button>
951
+ <Button
952
+ variant="ghost"
953
+ size="sm"
954
+ class={cn(
955
+ "size-7.5 p-0 rounded-md transition-colors",
956
+ isActive.h2
957
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
958
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
959
+ )}
960
+ onclick={() => setHeading(2)}
961
+ title="Heading 2"
962
+ >
963
+ <Heading2 class="size-3.5" />
964
+ </Button>
965
+ <Button
966
+ variant="ghost"
967
+ size="sm"
968
+ class={cn(
969
+ "size-7.5 p-0 rounded-md transition-colors",
970
+ isActive.h3
971
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
972
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
973
+ )}
974
+ onclick={() => setHeading(3)}
975
+ title="Heading 3"
976
+ >
977
+ <Heading3 class="size-3.5" />
978
+ </Button>
979
+ </div>
466
980
 
467
- <Separator orientation="vertical" class="h-6 mx-1" />
981
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
468
982
 
469
- <Button
470
- variant="ghost"
471
- size="sm"
472
- class={cn(
473
- 'size-8 p-0',
474
- isActive.bulletList && 'bg-[var(--ui-primary)]/10'
475
- )}
476
- onclick={toggleBulletList}
477
- >
478
- <List class="size-4" />
479
- </Button>
480
- <Button
481
- variant="ghost"
482
- size="sm"
483
- class={cn(
484
- 'size-8 p-0',
485
- isActive.orderedList && 'bg-[var(--ui-primary)]/10'
486
- )}
487
- onclick={toggleOrderedList}
488
- >
489
- <ListOrdered class="size-4" />
490
- </Button>
491
- <Button
492
- variant="ghost"
493
- size="sm"
494
- class={cn(
495
- 'size-8 p-0',
496
- isActive.blockquote && 'bg-[var(--ui-primary)]/10'
497
- )}
498
- onclick={toggleBlockquote}
499
- >
500
- <Quote class="size-4" />
501
- </Button>
502
- <Button
503
- variant="ghost"
504
- size="sm"
505
- class={cn(
506
- 'size-8 p-0',
507
- isActive.codeBlock && 'bg-[var(--ui-primary)]/10'
508
- )}
509
- onclick={toggleCodeBlock}
510
- >
511
- <Code class="size-4" />
512
- </Button>
983
+ <div class="flex items-center gap-0.5">
984
+ <Button
985
+ variant="ghost"
986
+ size="sm"
987
+ class={cn(
988
+ "size-7.5 p-0 rounded-md transition-colors",
989
+ isActive.bold
990
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
991
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
992
+ )}
993
+ onclick={toggleBold}
994
+ title="Bold (Ctrl+B)"
995
+ >
996
+ <Bold class="size-3.5" />
997
+ </Button>
998
+ <Button
999
+ variant="ghost"
1000
+ size="sm"
1001
+ class={cn(
1002
+ "size-7.5 p-0 rounded-md transition-colors",
1003
+ isActive.italic
1004
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1005
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1006
+ )}
1007
+ onclick={toggleItalic}
1008
+ title="Italic (Ctrl+I)"
1009
+ >
1010
+ <Italic class="size-3.5" />
1011
+ </Button>
1012
+ <Button
1013
+ variant="ghost"
1014
+ size="sm"
1015
+ class={cn(
1016
+ "size-7.5 p-0 rounded-md transition-colors",
1017
+ isActive.underline
1018
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1019
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1020
+ )}
1021
+ onclick={toggleUnderline}
1022
+ title="Underline (Ctrl+U)"
1023
+ >
1024
+ <UnderlineIcon class="size-3.5" />
1025
+ </Button>
1026
+ <Button
1027
+ variant="ghost"
1028
+ size="sm"
1029
+ class={cn(
1030
+ "size-7.5 p-0 rounded-md transition-colors",
1031
+ isActive.strike
1032
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1033
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1034
+ )}
1035
+ onclick={toggleStrike}
1036
+ title="Strikethrough"
1037
+ >
1038
+ <Strikethrough class="size-3.5" />
1039
+ </Button>
1040
+ <Button
1041
+ variant="ghost"
1042
+ size="sm"
1043
+ class={cn(
1044
+ "size-7.5 p-0 rounded-md transition-colors",
1045
+ isActive.highlight
1046
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1047
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1048
+ )}
1049
+ onclick={() => toggleHighlight("#fef08a")}
1050
+ title="Highlight text"
1051
+ >
1052
+ <Highlighter class="size-3.5" />
1053
+ </Button>
1054
+ <label
1055
+ class="relative flex size-7.5 cursor-pointer items-center justify-center rounded-md text-[var(--ui-muted-foreground)] hover:bg-[var(--ui-secondary)] hover:text-[var(--ui-foreground)] focus-within:ring-2 focus-within:ring-[var(--ui-ring)]"
1056
+ title="Text color"
1057
+ >
1058
+ <span class="text-xs font-bold" style="color: {textColor}">A</span>
1059
+ <input
1060
+ class="absolute inset-0 cursor-pointer opacity-0"
1061
+ type="color"
1062
+ value={textColor}
1063
+ aria-label="Text color"
1064
+ onchange={(event) =>
1065
+ setTextColor((event.currentTarget as HTMLInputElement).value)}
1066
+ />
1067
+ </label>
1068
+ <label
1069
+ class="relative flex size-7.5 cursor-pointer items-center justify-center rounded-md text-[var(--ui-muted-foreground)] hover:bg-[var(--ui-secondary)] hover:text-[var(--ui-foreground)] hover:text-[var(--ui-foreground)] focus-within:ring-2 focus-within:ring-[var(--ui-ring)]"
1070
+ title="Highlight color"
1071
+ >
1072
+ <Highlighter class="size-3.5" style="color: {highlightColor}" />
1073
+ <input
1074
+ class="absolute inset-0 cursor-pointer opacity-0"
1075
+ type="color"
1076
+ value={highlightColor}
1077
+ aria-label="Highlight color"
1078
+ onchange={(event) =>
1079
+ toggleHighlight((event.currentTarget as HTMLInputElement).value)}
1080
+ />
1081
+ </label>
1082
+ <Button
1083
+ variant="ghost"
1084
+ size="sm"
1085
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1086
+ onclick={toggleTextColor}
1087
+ title="Toggle text color"
1088
+ >
1089
+ <span class="text-xs font-bold">A</span>
1090
+ </Button>
1091
+ </div>
513
1092
 
514
- <Separator orientation="vertical" class="h-6 mx-1" />
1093
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
1094
+ <div class="flex items-center gap-0.5">
1095
+ <Button
1096
+ variant="ghost"
1097
+ size="sm"
1098
+ class={cn(
1099
+ "size-7.5 p-0 rounded-md transition-colors",
1100
+ isActive.bulletList
1101
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1102
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1103
+ )}
1104
+ onclick={toggleBulletList}
1105
+ title="Bullet list"
1106
+ >
1107
+ <List class="size-3.5" />
1108
+ </Button>
1109
+ <Button
1110
+ variant="ghost"
1111
+ size="sm"
1112
+ class={cn(
1113
+ "size-7.5 p-0 rounded-md transition-colors",
1114
+ isActive.orderedList
1115
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1116
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1117
+ )}
1118
+ onclick={toggleOrderedList}
1119
+ title="Numbered list"
1120
+ >
1121
+ <ListOrdered class="size-3.5" />
1122
+ </Button>
1123
+ <Button
1124
+ variant="ghost"
1125
+ size="sm"
1126
+ class={cn(
1127
+ "size-7.5 p-0 rounded-md transition-colors",
1128
+ isActive.blockquote
1129
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1130
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1131
+ )}
1132
+ onclick={toggleBlockquote}
1133
+ title="Quote"
1134
+ >
1135
+ <Quote class="size-3.5" />
1136
+ </Button>
1137
+ <Button
1138
+ variant="ghost"
1139
+ size="sm"
1140
+ class={cn(
1141
+ "size-7.5 p-0 rounded-md transition-colors",
1142
+ isActive.codeBlock
1143
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1144
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1145
+ )}
1146
+ onclick={toggleCodeBlock}
1147
+ title="Code block"
1148
+ >
1149
+ <Code class="size-3.5" />
1150
+ </Button>
1151
+ </div>
515
1152
 
516
- <Button
517
- variant="ghost"
518
- size="sm"
519
- class={cn(
520
- 'size-8 p-0',
521
- isActive.alignLeft && 'bg-[var(--ui-primary)]/10'
522
- )}
523
- onclick={() => setAlign('left')}
524
- >
525
- <AlignLeft class="size-4" />
526
- </Button>
527
- <Button
528
- variant="ghost"
529
- size="sm"
530
- class={cn(
531
- 'size-8 p-0',
532
- isActive.alignCenter && 'bg-[var(--ui-primary)]/10'
533
- )}
534
- onclick={() => setAlign('center')}
535
- >
536
- <AlignCenter class="size-4" />
537
- </Button>
538
- <Button
539
- variant="ghost"
540
- size="sm"
541
- class={cn(
542
- 'size-8 p-0',
543
- isActive.alignRight && 'bg-[var(--ui-primary)]/10'
544
- )}
545
- onclick={() => setAlign('right')}
546
- >
547
- <AlignRight class="size-4" />
548
- </Button>
549
- <Button
550
- variant="ghost"
551
- size="sm"
552
- class={cn(
553
- 'size-8 p-0',
554
- isActive.alignJustify && 'bg-[var(--ui-primary)]/10'
555
- )}
556
- onclick={() => setAlign('justify')}
557
- >
558
- <AlignJustify class="size-4" />
559
- </Button>
1153
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
560
1154
 
561
- <Separator orientation="vertical" class="h-6 mx-1" />
1155
+ <div class="flex items-center gap-0.5">
1156
+ <Button
1157
+ variant="ghost"
1158
+ size="sm"
1159
+ class={cn(
1160
+ "size-7.5 p-0 rounded-md transition-colors",
1161
+ isActive.alignLeft
1162
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1163
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1164
+ )}
1165
+ onclick={() => setAlign("left")}
1166
+ title="Align left"
1167
+ >
1168
+ <AlignLeft class="size-3.5" />
1169
+ </Button>
1170
+ <Button
1171
+ variant="ghost"
1172
+ size="sm"
1173
+ class={cn(
1174
+ "size-7.5 p-0 rounded-md transition-colors",
1175
+ isActive.alignCenter
1176
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1177
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1178
+ )}
1179
+ onclick={() => setAlign("center")}
1180
+ title="Align center"
1181
+ >
1182
+ <AlignCenter class="size-3.5" />
1183
+ </Button>
1184
+ <Button
1185
+ variant="ghost"
1186
+ size="sm"
1187
+ class={cn(
1188
+ "size-7.5 p-0 rounded-md transition-colors",
1189
+ isActive.alignRight
1190
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1191
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1192
+ )}
1193
+ onclick={() => setAlign("right")}
1194
+ title="Align right"
1195
+ >
1196
+ <AlignRight class="size-3.5" />
1197
+ </Button>
1198
+ <Button
1199
+ variant="ghost"
1200
+ size="sm"
1201
+ class={cn(
1202
+ "size-7.5 p-0 rounded-md transition-colors",
1203
+ isActive.alignJustify
1204
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1205
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1206
+ )}
1207
+ onclick={() => setAlign("justify")}
1208
+ title="Align justify"
1209
+ >
1210
+ <AlignJustify class="size-3.5" />
1211
+ </Button>
1212
+ </div>
562
1213
 
563
- <Button
564
- variant="ghost"
565
- size="sm"
566
- class={cn('size-8 p-0', isActive.link && 'bg-[var(--ui-primary)]/10')}
567
- onclick={setLink}
568
- >
569
- <LinkIcon class="size-4" />
570
- </Button>
571
- <Button variant="ghost" size="sm" class="size-8 p-0" onclick={setImage}>
572
- <ImageIcon class="size-4" />
573
- </Button>
574
- <Button variant="ghost" size="sm" class="size-8 p-0" onclick={insertTable}>
575
- <TableIcon class="size-4" />
576
- </Button>
577
- <Button
578
- variant="ghost"
579
- size="sm"
580
- class="size-8 p-0"
581
- onclick={addYoutube}
582
- title="Embed YouTube video"
583
- >
584
- <Video class="size-4" />
585
- </Button>
586
- <Button variant="ghost" size="sm" class="size-8 p-0" onclick={insertHorizontalRule}>
587
- <Minus class="size-4" />
588
- </Button>
1214
+ <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
589
1215
 
1216
+ <div class="flex items-center gap-0.5">
1217
+ <Button
1218
+ variant="ghost"
1219
+ size="sm"
1220
+ class={cn(
1221
+ "size-7.5 p-0 rounded-md transition-colors",
1222
+ isActive.link
1223
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1224
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1225
+ )}
1226
+ onclick={setLink}
1227
+ title="Insert link"
1228
+ >
1229
+ <LinkIcon class="size-3.5" />
1230
+ </Button>
1231
+ {#if isActive.link}
1232
+ <Button
1233
+ variant="ghost"
1234
+ size="sm"
1235
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1236
+ onclick={() => editor?.chain().focus().unsetLink().run()}
1237
+ title="Remove link"
1238
+ >
1239
+ <Unlink class="size-3.5" />
1240
+ </Button>
1241
+ {/if}
1242
+ <Button
1243
+ variant="ghost"
1244
+ size="sm"
1245
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1246
+ onclick={() => (showFindReplace = !showFindReplace)}
1247
+ title="Find and replace"
1248
+ >
1249
+ <Search class="size-3.5" />
1250
+ </Button>
1251
+ <Button
1252
+ variant="ghost"
1253
+ size="sm"
1254
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1255
+ onclick={exportHtml}
1256
+ title="Export HTML"
1257
+ >
1258
+ <Download class="size-3.5" /><span class="sr-only">Export HTML</span>
1259
+ </Button>
1260
+ <Button
1261
+ variant="ghost"
1262
+ size="sm"
1263
+ class="hidden size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)] sm:inline-flex"
1264
+ onclick={exportMarkdown}
1265
+ title="Export Markdown"
1266
+ >
1267
+ <FileText class="size-3.5" /><span class="sr-only"
1268
+ >Export Markdown</span
1269
+ >
1270
+ </Button>
1271
+ <Button
1272
+ variant="ghost"
1273
+ size="sm"
1274
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1275
+ onclick={setImage}
1276
+ title="Insert image"
1277
+ >
1278
+ <ImageIcon class="size-3.5" />
1279
+ </Button>
1280
+ <Button
1281
+ variant="ghost"
1282
+ size="sm"
1283
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1284
+ onclick={insertTable}
1285
+ title="Insert table"
1286
+ >
1287
+ <TableIcon class="size-3.5" />
1288
+ </Button>
1289
+ <Button
1290
+ variant="ghost"
1291
+ size="sm"
1292
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1293
+ onclick={addYoutube}
1294
+ title="Embed YouTube video"
1295
+ >
1296
+ <Video class="size-3.5" />
1297
+ </Button>
1298
+ <Button
1299
+ variant="ghost"
1300
+ size="sm"
1301
+ class="size-7.5 p-0 rounded-md text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]"
1302
+ onclick={insertHorizontalRule}
1303
+ title="Divider line"
1304
+ >
1305
+ <Minus class="size-3.5" />
1306
+ </Button>
1307
+ </div>
590
1308
  {#if uploadingCount > 0}
591
- <span class="ml-auto text-xs text-[var(--ui-muted-foreground)]">
592
- Uploading {uploadingCount} image{uploadingCount > 1 ? 's' : ''}...
1309
+ <span
1310
+ class="ml-auto text-xs text-[var(--ui-muted-foreground)] font-medium"
1311
+ >
1312
+ Uploading {uploadingCount} image{uploadingCount > 1 ? "s" : ""}...
593
1313
  </span>
594
1314
  {/if}
595
1315
  </div>
596
1316
  {/if}
597
-
598
- <!-- Editor -->
1317
+ {#if editable && mode === "classic" && showFindReplace}
1318
+ <div
1319
+ class="flex flex-wrap items-center gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/30 px-3 py-2"
1320
+ role="search"
1321
+ aria-label="Find and replace"
1322
+ >
1323
+ <Input
1324
+ class="h-8 min-w-32 flex-1 sm:max-w-56"
1325
+ bind:value={findQuery}
1326
+ placeholder="Find text"
1327
+ aria-label="Find text"
1328
+ oninput={updateSearchMatches}
1329
+ onkeydown={(event) => event.key === "Enter" && findNext()}
1330
+ />
1331
+ <Input
1332
+ class="h-8 min-w-32 flex-1 sm:max-w-56"
1333
+ bind:value={replaceQuery}
1334
+ placeholder="Replace with"
1335
+ aria-label="Replace with"
1336
+ />
1337
+ <span
1338
+ class="text-xs tabular-nums text-[var(--ui-muted-foreground)]"
1339
+ aria-live="polite">{findCount} match{findCount === 1 ? "" : "es"}</span
1340
+ >
1341
+ <Button variant="outline" size="sm" onclick={findNext}>Find next</Button>
1342
+ <Button variant="outline" size="sm" onclick={replaceCurrent}
1343
+ >Replace</Button
1344
+ >
1345
+ <Button variant="outline" size="sm" onclick={replaceAll}
1346
+ >Replace all</Button
1347
+ >
1348
+ </div>
1349
+ {/if}
599
1350
  <div
600
1351
  bind:this={editorEl}
601
- class="prose prose-sm max-w-none p-4 focus:outline-none"
1352
+ class="rich-text-content prose prose-sm max-w-none p-5 sm:p-7 focus:outline-none"
602
1353
  style="min-height: {height}px;"
603
1354
  ></div>
604
1355
 
1356
+ <!-- Slash Command Popover Menu -->
1357
+ {#if showSlashMenu && slashFilteredCommands.length > 0}
1358
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1359
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1360
+ <div
1361
+ 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"
1362
+ style="top: {slashMenuPos.top}px; left: {slashMenuPos.left}px;"
1363
+ >
1364
+ <div
1365
+ class="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-[var(--ui-muted-foreground)]"
1366
+ >
1367
+ Basic blocks
1368
+ </div>
1369
+ {#each slashFilteredCommands as cmd, i (cmd.title)}
1370
+ {@const Icon = cmd.icon}
1371
+ <button
1372
+ type="button"
1373
+ class={cn(
1374
+ "flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left transition-colors cursor-pointer",
1375
+ i === slashIndex
1376
+ ? "bg-[var(--ui-accent)] text-[var(--ui-accent-foreground)]"
1377
+ : "hover:bg-[var(--ui-secondary)]",
1378
+ )}
1379
+ onmouseenter={() => (slashIndex = i)}
1380
+ onclick={() => executeSlashCommand(cmd)}
1381
+ >
1382
+ <div
1383
+ class="flex size-7 items-center justify-center rounded-md border border-[var(--ui-border)] bg-[var(--ui-card)] shrink-0"
1384
+ >
1385
+ <Icon class="size-4 text-[var(--ui-foreground)]" />
1386
+ </div>
1387
+ <div class="flex flex-col min-w-0">
1388
+ <span class="text-xs font-semibold text-[var(--ui-foreground)]"
1389
+ >{cmd.title}</span
1390
+ >
1391
+ <span class="text-[10px] text-[var(--ui-muted-foreground)] truncate"
1392
+ >{cmd.desc}</span
1393
+ >
1394
+ </div>
1395
+ </button>
1396
+ {/each}
1397
+ </div>
1398
+ {/if}
1399
+
605
1400
  <!-- BubbleMenu: floating toolbar on text selection (element bound for TipTap) -->
606
1401
  <div
607
1402
  bind:this={bubbleMenuEl}
608
- class="flex items-center gap-0.5 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-1 shadow-lg backdrop-blur-sm"
1403
+ 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"
609
1404
  >
610
1405
  {#if editor}
611
1406
  <Button
612
1407
  variant="ghost"
613
1408
  size="sm"
614
- class={cn('size-7 p-0', editor.isActive('bold') && 'bg-[var(--ui-primary)]/10')}
1409
+ class={cn(
1410
+ "size-7 p-0 rounded-md transition-colors",
1411
+ editor.isActive("bold")
1412
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1413
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1414
+ )}
615
1415
  onclick={toggleBold}
616
1416
  >
617
1417
  <Bold class="size-3.5" />
@@ -619,7 +1419,12 @@
619
1419
  <Button
620
1420
  variant="ghost"
621
1421
  size="sm"
622
- class={cn('size-7 p-0', editor.isActive('italic') && 'bg-[var(--ui-primary)]/10')}
1422
+ class={cn(
1423
+ "size-7 p-0 rounded-md transition-colors",
1424
+ editor.isActive("italic")
1425
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1426
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1427
+ )}
623
1428
  onclick={toggleItalic}
624
1429
  >
625
1430
  <Italic class="size-3.5" />
@@ -627,7 +1432,12 @@
627
1432
  <Button
628
1433
  variant="ghost"
629
1434
  size="sm"
630
- class={cn('size-7 p-0', editor.isActive('underline') && 'bg-[var(--ui-primary)]/10')}
1435
+ class={cn(
1436
+ "size-7 p-0 rounded-md transition-colors",
1437
+ editor.isActive("underline")
1438
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1439
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1440
+ )}
631
1441
  onclick={toggleUnderline}
632
1442
  >
633
1443
  <UnderlineIcon class="size-3.5" />
@@ -635,7 +1445,12 @@
635
1445
  <Button
636
1446
  variant="ghost"
637
1447
  size="sm"
638
- class={cn('size-7 p-0', editor.isActive('strike') && 'bg-[var(--ui-primary)]/10')}
1448
+ class={cn(
1449
+ "size-7 p-0 rounded-md transition-colors",
1450
+ editor.isActive("strike")
1451
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1452
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1453
+ )}
639
1454
  onclick={toggleStrike}
640
1455
  >
641
1456
  <Strikethrough class="size-3.5" />
@@ -644,17 +1459,24 @@
644
1459
  variant="ghost"
645
1460
  size="sm"
646
1461
  class={cn(
647
- 'size-7 p-0',
648
- editor.isActive('highlight') && 'bg-[var(--ui-primary)]/10'
1462
+ "size-7 p-0 rounded-md transition-colors",
1463
+ editor.isActive("highlight")
1464
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1465
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
649
1466
  )}
650
- onclick={() => toggleHighlight('#fef08a')}
1467
+ onclick={() => toggleHighlight("#fef08a")}
651
1468
  >
652
1469
  <Highlighter class="size-3.5" />
653
1470
  </Button>
654
1471
  <Button
655
1472
  variant="ghost"
656
1473
  size="sm"
657
- class={cn('size-7 p-0', editor.isActive('link') && 'bg-[var(--ui-primary)]/10')}
1474
+ class={cn(
1475
+ "size-7 p-0 rounded-md transition-colors",
1476
+ editor.isActive("link")
1477
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)] font-bold shadow-xs"
1478
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)] hover:bg-[var(--ui-secondary)]",
1479
+ )}
658
1480
  onclick={setLink}
659
1481
  >
660
1482
  <LinkIcon class="size-3.5" />
@@ -703,6 +1525,55 @@
703
1525
  margin-left: 0;
704
1526
  color: var(--ui-muted-foreground);
705
1527
  }
1528
+ /* Task List / Checklists */
1529
+ :global(.tiptap ul[data-type="taskList"]) {
1530
+ list-style: none;
1531
+ padding: 0;
1532
+ }
1533
+ :global(.tiptap ul[data-type="taskList"] li) {
1534
+ display: flex;
1535
+ align-items: flex-start;
1536
+ gap: 0.5rem;
1537
+ margin: 0.25rem 0;
1538
+ }
1539
+ :global(.tiptap ul[data-type="taskList"] li > label) {
1540
+ user-select: none;
1541
+ margin-top: 0.2rem;
1542
+ }
1543
+ :global(.tiptap ul[data-type="taskList"] li > label input[type="checkbox"]) {
1544
+ cursor: pointer;
1545
+ accent-color: var(--ui-primary);
1546
+ width: 1rem;
1547
+ height: 1rem;
1548
+ border-radius: 0.25rem;
1549
+ }
1550
+ :global(.tiptap ul[data-type="taskList"] li[data-checked="true"] > div) {
1551
+ text-decoration: line-through;
1552
+ color: var(--ui-muted-foreground);
1553
+ }
1554
+
1555
+ /* Callout Boxes */
1556
+ :global(.tiptap .callout-box) {
1557
+ background: var(--ui-secondary);
1558
+ border-radius: 0.75rem;
1559
+ padding: 0.875rem 1rem;
1560
+ border: 1px solid var(--ui-border);
1561
+ margin: 0.75rem 0;
1562
+ display: flex;
1563
+ gap: 0.75rem;
1564
+ }
1565
+ :global(.tiptap .callout-info) {
1566
+ background: color-mix(in oklch, var(--ui-info) 8%, transparent);
1567
+ border-color: color-mix(in oklch, var(--ui-info) 30%, transparent);
1568
+ }
1569
+ :global(.tiptap .callout-warning) {
1570
+ background: color-mix(in oklch, var(--ui-warning) 8%, transparent);
1571
+ border-color: color-mix(in oklch, var(--ui-warning) 30%, transparent);
1572
+ }
1573
+ :global(.tiptap .callout-tip) {
1574
+ background: color-mix(in oklch, var(--ui-success) 8%, transparent);
1575
+ border-color: color-mix(in oklch, var(--ui-success) 30%, transparent);
1576
+ }
706
1577
  :global(.tiptap pre) {
707
1578
  background: var(--ui-secondary);
708
1579
  border-radius: 0.5rem;
@@ -759,7 +1630,7 @@
759
1630
  background: color-mix(in srgb, var(--ui-primary) 10%, transparent);
760
1631
  }
761
1632
  :global(.tiptap .selectedCell::after) {
762
- content: '';
1633
+ content: "";
763
1634
  position: absolute;
764
1635
  inset: 0;
765
1636
  background: rgba(0, 0, 0, 0.05);
@@ -800,4 +1671,180 @@
800
1671
  padding: 0.1em 0.2em;
801
1672
  border-radius: 2px;
802
1673
  }
1674
+
1675
+ /* Rich-text surface contract: survives host Tailwind scanning boundaries. */
1676
+ :global(.rich-text-editor) {
1677
+ --rich-text-content-width: 72ch;
1678
+ color: var(--ui-foreground);
1679
+ }
1680
+ :global(.rich-text-toolbar) {
1681
+ scrollbar-width: thin;
1682
+ scrollbar-color: var(--ui-border) transparent;
1683
+ }
1684
+ :global(.rich-text-toolbar > div) {
1685
+ flex-shrink: 0;
1686
+ }
1687
+ :global(.rich-text-content) {
1688
+ overflow-y: auto;
1689
+ overscroll-behavior: contain;
1690
+ background: var(--ui-card);
1691
+ }
1692
+ :global(.rich-text-content .tiptap) {
1693
+ max-width: var(--rich-text-content-width);
1694
+ margin: 0 auto;
1695
+ }
1696
+ :global(.rich-text-content .tiptap ::selection) {
1697
+ background: color-mix(in oklch, var(--ui-primary) 24%, transparent);
1698
+ }
1699
+ :global(.rich-text-content .tiptap p.is-editor-empty:first-child::before) {
1700
+ color: color-mix(in oklch, var(--ui-muted-foreground) 80%, transparent);
1701
+ }
1702
+ :global(.rich-text-content .tiptap:focus) {
1703
+ outline: none;
1704
+ }
1705
+
1706
+ /* Polished document surface */
1707
+ :global(.rich-text-content .tiptap) {
1708
+ color: var(--ui-foreground);
1709
+ caret-color: var(--ui-primary);
1710
+ line-height: 1.75;
1711
+ font-size: 0.9375rem;
1712
+ }
1713
+ :global(.rich-text-content .tiptap > :first-child) {
1714
+ margin-top: 0;
1715
+ }
1716
+ :global(.rich-text-content .tiptap > :last-child) {
1717
+ margin-bottom: 0;
1718
+ }
1719
+ :global(.tiptap p) {
1720
+ margin: 0.65rem 0;
1721
+ }
1722
+ :global(.tiptap h1),
1723
+ :global(.tiptap h2),
1724
+ :global(.tiptap h3) {
1725
+ color: var(--ui-foreground);
1726
+ letter-spacing: -0.025em;
1727
+ line-height: 1.25;
1728
+ }
1729
+ :global(.tiptap h1) {
1730
+ font-size: clamp(1.5rem, 3vw, 2rem);
1731
+ margin: 1.75rem 0 0.75rem;
1732
+ }
1733
+ :global(.tiptap h2) {
1734
+ font-size: clamp(1.25rem, 2.5vw, 1.5rem);
1735
+ margin: 1.5rem 0 0.65rem;
1736
+ }
1737
+ :global(.tiptap h3) {
1738
+ font-size: 1.125rem;
1739
+ margin: 1.25rem 0 0.5rem;
1740
+ }
1741
+ :global(.tiptap strong) {
1742
+ font-weight: 700;
1743
+ color: var(--ui-foreground);
1744
+ }
1745
+ :global(.tiptap a:not(.image-link)) {
1746
+ color: var(--ui-primary);
1747
+ text-decoration: underline;
1748
+ text-decoration-color: color-mix(
1749
+ in oklch,
1750
+ var(--ui-primary) 35%,
1751
+ transparent
1752
+ );
1753
+ text-underline-offset: 3px;
1754
+ }
1755
+ :global(.tiptap a:not(.image-link):hover) {
1756
+ text-decoration-color: var(--ui-primary);
1757
+ }
1758
+ :global(.tiptap ul),
1759
+ :global(.tiptap ol) {
1760
+ margin: 0.75rem 0;
1761
+ }
1762
+ :global(.tiptap li + li) {
1763
+ margin-top: 0.25rem;
1764
+ }
1765
+ :global(.tiptap blockquote) {
1766
+ background: color-mix(in oklch, var(--ui-primary) 5%, transparent);
1767
+ border-left-width: 3px;
1768
+ border-radius: 0 0.5rem 0.5rem 0;
1769
+ padding: 0.75rem 1rem;
1770
+ margin: 1rem 0;
1771
+ }
1772
+ :global(.tiptap pre) {
1773
+ border: 1px solid var(--ui-border);
1774
+ background: var(--ui-secondary);
1775
+ color: var(--ui-foreground);
1776
+ box-shadow: inset 0 1px 0 color-mix(in oklch, white 5%, transparent);
1777
+ overflow-x: auto;
1778
+ }
1779
+ :global(.tiptap code:not(pre code)) {
1780
+ color: var(--ui-primary);
1781
+ border: 1px solid color-mix(in oklch, var(--ui-primary) 15%, transparent);
1782
+ }
1783
+ :global(.tiptap table) {
1784
+ display: block;
1785
+ overflow-x: auto;
1786
+ border: 1px solid var(--ui-border);
1787
+ border-radius: 0.625rem;
1788
+ background: var(--ui-card);
1789
+ }
1790
+ :global(.tiptap th),
1791
+ :global(.tiptap td) {
1792
+ min-width: 7rem;
1793
+ border-color: var(--ui-border);
1794
+ padding: 0.625rem 0.75rem;
1795
+ }
1796
+ :global(.tiptap th) {
1797
+ color: var(--ui-foreground);
1798
+ background: var(--ui-secondary);
1799
+ font-size: 0.8125rem;
1800
+ }
1801
+ :global(.tiptap td) {
1802
+ color: var(--ui-foreground);
1803
+ }
1804
+ :global(.tiptap .selectedCell) {
1805
+ background: color-mix(in oklch, var(--ui-primary) 12%, transparent);
1806
+ }
1807
+ :global(.tiptap .selectedCell::after) {
1808
+ background: color-mix(in oklch, var(--ui-primary) 10%, transparent);
1809
+ }
1810
+ :global(.tiptap img) {
1811
+ border: 1px solid var(--ui-border);
1812
+ box-shadow: 0 8px 24px
1813
+ color-mix(in oklch, var(--ui-foreground) 10%, transparent);
1814
+ transition: box-shadow 150ms ease-out;
1815
+ }
1816
+ :global(.tiptap img:hover) {
1817
+ box-shadow: 0 12px 30px
1818
+ color-mix(in oklch, var(--ui-foreground) 16%, transparent);
1819
+ }
1820
+ :global(.tiptap .callout-box) {
1821
+ box-shadow: 0 1px 2px
1822
+ color-mix(in oklch, var(--ui-foreground) 6%, transparent);
1823
+ }
1824
+ :global(.tiptap hr) {
1825
+ border-top-color: var(--ui-border);
1826
+ margin: 1.5rem 0;
1827
+ }
1828
+ :global(.tiptap:focus-visible) {
1829
+ outline: none;
1830
+ }
1831
+
1832
+ @media (max-width: 640px) {
1833
+ :global(.rich-text-toolbar) {
1834
+ scrollbar-width: none;
1835
+ }
1836
+ :global(.rich-text-toolbar::-webkit-scrollbar) {
1837
+ display: none;
1838
+ }
1839
+ :global(.rich-text-content .tiptap) {
1840
+ font-size: 0.875rem;
1841
+ }
1842
+ }
1843
+
1844
+ @media (prefers-reduced-motion: reduce) {
1845
+ :global(.rich-text-editor),
1846
+ :global(.tiptap img) {
1847
+ transition: none;
1848
+ }
1849
+ }
803
1850
  </style>