@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.
- package/dist/components/RichTextEditor.svelte +1223 -0
- package/dist/components/RichTextEditor.svelte.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/tracked-changes.d.ts +31 -0
- package/dist/tracked-changes.js +106 -0
- package/package.json +54 -0
|
@@ -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>
|