@intinyagroup/rich-text 0.1.1-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,803 @@
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 {
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
+ Video,
23
+ } from 'lucide-svelte';
24
+ import { Button, Separator } from '@intinyagroup/ui';
25
+ import { cn } from '@intinyagroup/grid-core/utils';
26
+
27
+ let {
28
+ content = '',
29
+ placeholder = 'Start writing...',
30
+ editable = true,
31
+ height = 400,
32
+ class: className,
33
+ onUpdate,
34
+ onImageUpload,
35
+ }: {
36
+ content?: string;
37
+ placeholder?: string;
38
+ editable?: boolean;
39
+ height?: number;
40
+ class?: string;
41
+ onUpdate?: (html: string) => void;
42
+ /** Called when paste/drop provides an image file. Return a URL to insert. */
43
+ onImageUpload?: (file: File) => Promise<string>;
44
+ } = $props();
45
+
46
+ let editorEl: HTMLDivElement | null = null;
47
+ let bubbleMenuEl: HTMLDivElement | null = null;
48
+ let editor: Editor | null = null;
49
+ let isActive = $state<Record<string, boolean>>({});
50
+ let uploadingCount = $state(0);
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Custom Extensions
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Image with href/alt/title attributes — renders as <a><img></a> when href set */
57
+ const CustomImage = Image.extend({
58
+ addAttributes() {
59
+ return {
60
+ ...this.parent?.(),
61
+ href: { default: null },
62
+ alt: { default: '' },
63
+ title: { default: '' },
64
+ target: { default: '_blank' },
65
+ };
66
+ },
67
+ renderHTML({ HTMLAttributes }) {
68
+ const { href, target, alt, title, ...rest } = HTMLAttributes;
69
+ const img = ['img', mergeAttributes(this.options.HTMLAttributes, rest, { alt, title })];
70
+ if (href) {
71
+ return ['a', { href, target, class: 'image-link' }, img];
72
+ }
73
+ return img;
74
+ },
75
+ });
76
+
77
+ /** YouTube iframe embed node */
78
+ const CustomYoutube = Node.create({
79
+ name: 'youtube',
80
+ group: 'block',
81
+ atom: true,
82
+ addAttributes() {
83
+ return { src: { default: null } };
84
+ },
85
+ parseHTML() {
86
+ return [
87
+ { tag: "iframe[src*='youtube.com']" },
88
+ { tag: "iframe[src*='youtu.be']" },
89
+ ];
90
+ },
91
+ renderHTML({ HTMLAttributes }) {
92
+ return [
93
+ 'div',
94
+ { class: 'video-wrapper' },
95
+ [
96
+ 'iframe',
97
+ mergeAttributes(HTMLAttributes, {
98
+ width: '100%',
99
+ height: '315',
100
+ allowfullscreen: 'true',
101
+ }),
102
+ ],
103
+ ];
104
+ },
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Image Compression
109
+ // ---------------------------------------------------------------------------
110
+
111
+ async function compressImage(file: File): Promise<File> {
112
+ if (file.size < 100 * 1024) return file;
113
+
114
+ return new Promise((resolve) => {
115
+ const reader = new FileReader();
116
+ reader.onload = (e) => {
117
+ const img = new Image();
118
+ img.onload = () => {
119
+ const canvas = document.createElement('canvas');
120
+ const ctx = canvas.getContext('2d');
121
+ if (!ctx) {
122
+ resolve(file);
123
+ return;
124
+ }
125
+
126
+ let { width, height } = img;
127
+ const maxDim = 2048;
128
+
129
+ if (width > maxDim || height > maxDim) {
130
+ if (width > height) {
131
+ height = (height / width) * maxDim;
132
+ width = maxDim;
133
+ } else {
134
+ width = (width / height) * maxDim;
135
+ height = maxDim;
136
+ }
137
+ }
138
+
139
+ canvas.width = width;
140
+ canvas.height = height;
141
+ ctx.drawImage(img, 0, 0, width, height);
142
+
143
+ canvas.toBlob(
144
+ (blob) => {
145
+ if (blob) {
146
+ resolve(new File([blob], file.name, { type: 'image/jpeg' }));
147
+ } else {
148
+ resolve(file);
149
+ }
150
+ },
151
+ 'image/jpeg',
152
+ 0.85
153
+ );
154
+ };
155
+ img.src = e.target?.result as string;
156
+ };
157
+ reader.readAsDataURL(file);
158
+ });
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Image Upload (paste / drop)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ async function uploadAndInsertImage(file: File) {
166
+ if (!editor) return;
167
+ uploadingCount++;
168
+ try {
169
+ let src: string;
170
+ if (onImageUpload) {
171
+ const compressed = await compressImage(file);
172
+ src = await onImageUpload(compressed);
173
+ } else {
174
+ // 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
+ });
180
+ }
181
+ editor.chain().focus().setImage({ src }).run();
182
+ } catch (err) {
183
+ console.error('Failed to upload image:', err);
184
+ } finally {
185
+ uploadingCount--;
186
+ }
187
+ }
188
+
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));
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // YouTube Embed
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function addYoutube() {
199
+ const url = window.prompt('Enter YouTube URL:');
200
+ if (!url || !editor) return;
201
+ const match = url.match(
202
+ /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=|shorts\/)([^#&?]*).*/
203
+ );
204
+ const id = match && match[2].length === 11 ? match[2] : null;
205
+ if (id) {
206
+ editor
207
+ .chain()
208
+ .focus()
209
+ .insertContent({
210
+ type: 'youtube',
211
+ attrs: { src: `https://www.youtube.com/embed/${id}` },
212
+ })
213
+ .run();
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Toolbar Helpers
219
+ // ---------------------------------------------------------------------------
220
+
221
+ function getActiveStates(e: Editor): Record<string, boolean> {
222
+ 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'),
240
+ };
241
+ }
242
+
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();
254
+ }
255
+ function setLink() {
256
+ const url = window.prompt('Enter URL:');
257
+ if (url) editor?.chain().focus().setLink({ href: url }).run();
258
+ }
259
+ function setImage() {
260
+ const url = window.prompt('Enter image URL:');
261
+ if (url) editor?.chain().focus().setImage({ src: url }).run();
262
+ }
263
+ function insertHorizontalRule() { editor?.chain().focus().setHorizontalRule().run(); }
264
+ function insertTable() {
265
+ editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
266
+ }
267
+ function toggleHighlight(color?: string) {
268
+ if (color) {
269
+ editor?.chain().focus().toggleHighlight({ color }).run();
270
+ } else {
271
+ editor?.chain().focus().toggleHighlight().run();
272
+ }
273
+ }
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
+
284
+ // ---------------------------------------------------------------------------
285
+ // Lifecycle
286
+ // ---------------------------------------------------------------------------
287
+
288
+ onMount(() => {
289
+ if (!editorEl) return;
290
+
291
+ editor = new Editor({
292
+ element: editorEl,
293
+ extensions: [
294
+ StarterKit,
295
+ Placeholder.configure({ placeholder }),
296
+ Underline,
297
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
298
+ Link.configure({ openOnClick: false }),
299
+ CustomImage.configure({
300
+ inline: false,
301
+ HTMLAttributes: {
302
+ class: 'rounded-lg mx-auto block max-w-full h-auto cursor-pointer',
303
+ },
304
+ }),
305
+ Highlight.configure({ multicolor: true }),
306
+ Typography,
307
+ Table.configure({ resizable: true }),
308
+ TableRow,
309
+ TableCell,
310
+ TableHeader,
311
+ BubbleMenu.configure({
312
+ element: bubbleMenuEl!,
313
+ pluginKey: 'bubbleMenu',
314
+ shouldShow: ({ editor: e }) =>
315
+ e.isEditable &&
316
+ !e.isActive('table') &&
317
+ e.view.state.selection.content().size > 0,
318
+ }),
319
+ CustomYoutube,
320
+ ],
321
+ content,
322
+ editable,
323
+ onUpdate: ({ editor: e }) => {
324
+ const html = e.getHTML();
325
+ isActive = getActiveStates(e);
326
+ onUpdate?.(html);
327
+ },
328
+ onSelectionUpdate: ({ editor: e }) => {
329
+ isActive = getActiveStates(e);
330
+ },
331
+ editorProps: {
332
+ attributes: {
333
+ class: 'prose prose-sm max-w-none focus:outline-none',
334
+ },
335
+ handleDOMEvents: {
336
+ paste: (_view, event) => {
337
+ const items = event.clipboardData?.items;
338
+ if (!items) return false;
339
+ const imageFiles: File[] = [];
340
+ for (let i = 0; i < items.length; i++) {
341
+ if (items[i].type.startsWith('image/')) {
342
+ const file = items[i].getAsFile();
343
+ if (file) imageFiles.push(file);
344
+ }
345
+ }
346
+ if (imageFiles.length) {
347
+ event.preventDefault();
348
+ handleFiles(imageFiles);
349
+ return true;
350
+ }
351
+ return false;
352
+ },
353
+ drop: (_view, event) => {
354
+ const files = event.dataTransfer?.files;
355
+ if (!files?.length) return false;
356
+ const imageFiles = Array.from(files).filter((f) =>
357
+ f.type.startsWith('image/')
358
+ );
359
+ if (imageFiles.length) {
360
+ event.preventDefault();
361
+ handleFiles(imageFiles);
362
+ return true;
363
+ }
364
+ return false;
365
+ },
366
+ },
367
+ },
368
+ });
369
+ });
370
+
371
+ onDestroy(() => {
372
+ if (editor) editor.destroy();
373
+ });
374
+ </script>
375
+
376
+ <div
377
+ class={cn(
378
+ 'rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] overflow-hidden',
379
+ className
380
+ )}
381
+ >
382
+ <!-- Toolbar -->
383
+ {#if editable}
384
+ <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"
386
+ >
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>
420
+
421
+ <Separator orientation="vertical" class="h-6 mx-1" />
422
+
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>
466
+
467
+ <Separator orientation="vertical" class="h-6 mx-1" />
468
+
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>
513
+
514
+ <Separator orientation="vertical" class="h-6 mx-1" />
515
+
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>
560
+
561
+ <Separator orientation="vertical" class="h-6 mx-1" />
562
+
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>
589
+
590
+ {#if uploadingCount > 0}
591
+ <span class="ml-auto text-xs text-[var(--ui-muted-foreground)]">
592
+ Uploading {uploadingCount} image{uploadingCount > 1 ? 's' : ''}...
593
+ </span>
594
+ {/if}
595
+ </div>
596
+ {/if}
597
+
598
+ <!-- Editor -->
599
+ <div
600
+ bind:this={editorEl}
601
+ class="prose prose-sm max-w-none p-4 focus:outline-none"
602
+ style="min-height: {height}px;"
603
+ ></div>
604
+
605
+ <!-- BubbleMenu: floating toolbar on text selection (element bound for TipTap) -->
606
+ <div
607
+ 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"
609
+ >
610
+ {#if editor}
611
+ <Button
612
+ variant="ghost"
613
+ size="sm"
614
+ class={cn('size-7 p-0', editor.isActive('bold') && 'bg-[var(--ui-primary)]/10')}
615
+ onclick={toggleBold}
616
+ >
617
+ <Bold class="size-3.5" />
618
+ </Button>
619
+ <Button
620
+ variant="ghost"
621
+ size="sm"
622
+ class={cn('size-7 p-0', editor.isActive('italic') && 'bg-[var(--ui-primary)]/10')}
623
+ onclick={toggleItalic}
624
+ >
625
+ <Italic class="size-3.5" />
626
+ </Button>
627
+ <Button
628
+ variant="ghost"
629
+ size="sm"
630
+ class={cn('size-7 p-0', editor.isActive('underline') && 'bg-[var(--ui-primary)]/10')}
631
+ onclick={toggleUnderline}
632
+ >
633
+ <UnderlineIcon class="size-3.5" />
634
+ </Button>
635
+ <Button
636
+ variant="ghost"
637
+ size="sm"
638
+ class={cn('size-7 p-0', editor.isActive('strike') && 'bg-[var(--ui-primary)]/10')}
639
+ onclick={toggleStrike}
640
+ >
641
+ <Strikethrough class="size-3.5" />
642
+ </Button>
643
+ <Button
644
+ variant="ghost"
645
+ size="sm"
646
+ class={cn(
647
+ 'size-7 p-0',
648
+ editor.isActive('highlight') && 'bg-[var(--ui-primary)]/10'
649
+ )}
650
+ onclick={() => toggleHighlight('#fef08a')}
651
+ >
652
+ <Highlighter class="size-3.5" />
653
+ </Button>
654
+ <Button
655
+ variant="ghost"
656
+ size="sm"
657
+ class={cn('size-7 p-0', editor.isActive('link') && 'bg-[var(--ui-primary)]/10')}
658
+ onclick={setLink}
659
+ >
660
+ <LinkIcon class="size-3.5" />
661
+ </Button>
662
+ {/if}
663
+ </div>
664
+ </div>
665
+
666
+ <style>
667
+ :global(.tiptap) {
668
+ outline: none;
669
+ }
670
+ :global(.tiptap p.is-editor-empty:first-child::before) {
671
+ content: attr(data-placeholder);
672
+ float: left;
673
+ color: var(--ui-muted-foreground);
674
+ pointer-events: none;
675
+ height: 0;
676
+ }
677
+ :global(.tiptap h1) {
678
+ font-size: 1.5rem;
679
+ font-weight: 700;
680
+ margin: 0.5rem 0;
681
+ }
682
+ :global(.tiptap h2) {
683
+ font-size: 1.25rem;
684
+ font-weight: 600;
685
+ margin: 0.5rem 0;
686
+ }
687
+ :global(.tiptap h3) {
688
+ font-size: 1.125rem;
689
+ font-weight: 600;
690
+ margin: 0.5rem 0;
691
+ }
692
+ :global(.tiptap ul) {
693
+ list-style-type: disc;
694
+ padding-left: 1.5rem;
695
+ }
696
+ :global(.tiptap ol) {
697
+ list-style-type: decimal;
698
+ padding-left: 1.5rem;
699
+ }
700
+ :global(.tiptap blockquote) {
701
+ border-left: 3px solid var(--ui-primary);
702
+ padding-left: 1rem;
703
+ margin-left: 0;
704
+ color: var(--ui-muted-foreground);
705
+ }
706
+ :global(.tiptap pre) {
707
+ background: var(--ui-secondary);
708
+ border-radius: 0.5rem;
709
+ padding: 0.75rem 1rem;
710
+ font-family: monospace;
711
+ font-size: 0.875rem;
712
+ }
713
+ :global(.tiptap code) {
714
+ background: var(--ui-secondary);
715
+ border-radius: 0.25rem;
716
+ padding: 0.125rem 0.25rem;
717
+ font-size: 0.875em;
718
+ }
719
+ :global(.tiptap pre code) {
720
+ background: none;
721
+ padding: 0;
722
+ }
723
+ :global(.tiptap img) {
724
+ max-width: 100%;
725
+ border-radius: 0.5rem;
726
+ }
727
+ :global(.tiptap a.image-link) {
728
+ display: block;
729
+ text-align: center;
730
+ }
731
+ :global(.tiptap a.image-link img) {
732
+ display: block;
733
+ margin: 0 auto;
734
+ }
735
+ :global(.tiptap hr) {
736
+ border: none;
737
+ border-top: 1px solid var(--ui-border);
738
+ margin: 1rem 0;
739
+ }
740
+ :global(.tiptap table) {
741
+ border-collapse: collapse;
742
+ width: 100%;
743
+ margin: 1em 0;
744
+ overflow: hidden;
745
+ }
746
+ :global(.tiptap th),
747
+ :global(.tiptap td) {
748
+ border: 1px solid var(--ui-border);
749
+ padding: 0.5rem 0.75rem;
750
+ text-align: left;
751
+ position: relative;
752
+ min-width: 80px;
753
+ }
754
+ :global(.tiptap th) {
755
+ background: var(--ui-secondary);
756
+ font-weight: 600;
757
+ }
758
+ :global(.tiptap td.selectedCell) {
759
+ background: color-mix(in srgb, var(--ui-primary) 10%, transparent);
760
+ }
761
+ :global(.tiptap .selectedCell::after) {
762
+ content: '';
763
+ position: absolute;
764
+ inset: 0;
765
+ background: rgba(0, 0, 0, 0.05);
766
+ pointer-events: none;
767
+ }
768
+ :global(.tiptap .column-resize-handle) {
769
+ position: absolute;
770
+ right: -2px;
771
+ top: 0;
772
+ bottom: 0;
773
+ width: 4px;
774
+ background: var(--ui-primary);
775
+ cursor: col-resize;
776
+ }
777
+
778
+ /* YouTube / Video embed */
779
+ :global(.tiptap .video-wrapper) {
780
+ position: relative;
781
+ padding-bottom: 56.25%;
782
+ height: 0;
783
+ overflow: hidden;
784
+ margin: 1.5rem 0;
785
+ border-radius: 0.75rem;
786
+ background: var(--ui-secondary);
787
+ }
788
+ :global(.tiptap .video-wrapper iframe) {
789
+ position: absolute;
790
+ top: 0;
791
+ left: 0;
792
+ width: 100%;
793
+ height: 100%;
794
+ border: 0;
795
+ }
796
+
797
+ /* Typography smart-quotes styling (cosmetic, TipTap handles conversion) */
798
+ :global(.tiptap mark) {
799
+ background-color: #fef08a;
800
+ padding: 0.1em 0.2em;
801
+ border-radius: 2px;
802
+ }
803
+ </style>
@@ -0,0 +1,13 @@
1
+ type $$ComponentProps = {
2
+ content?: string;
3
+ placeholder?: string;
4
+ editable?: boolean;
5
+ height?: number;
6
+ class?: string;
7
+ onUpdate?: (html: string) => void;
8
+ /** Called when paste/drop provides an image file. Return a URL to insert. */
9
+ onImageUpload?: (file: File) => Promise<string>;
10
+ };
11
+ declare const RichTextEditor: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type RichTextEditor = ReturnType<typeof RichTextEditor>;
13
+ export default RichTextEditor;
@@ -0,0 +1 @@
1
+ export { default as RichTextEditor } from './components/RichTextEditor.svelte';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { default as RichTextEditor } from './components/RichTextEditor.svelte';
@@ -0,0 +1,31 @@
1
+ export type TrackedChange = {
2
+ id: string;
3
+ type: 'insertion' | 'deletion';
4
+ userId: string;
5
+ userName: string;
6
+ timestamp: string;
7
+ content: string;
8
+ accepted: boolean;
9
+ };
10
+ export type TrackedChangeState = {
11
+ enabled: boolean;
12
+ changes: TrackedChange[];
13
+ currentUserId: string;
14
+ currentUserName: string;
15
+ };
16
+ export declare function createTrackedChangeState(userId: string, userName: string): TrackedChangeState;
17
+ export declare function addTrackedChange(state: TrackedChangeState, type: 'insertion' | 'deletion', content: string): TrackedChange;
18
+ export declare function acceptChange(state: TrackedChangeState, changeId: string): TrackedChangeState;
19
+ export declare function rejectChange(state: TrackedChangeState, changeId: string): TrackedChangeState;
20
+ export declare function acceptAllChanges(state: TrackedChangeState): TrackedChangeState;
21
+ export declare function rejectAllChanges(state: TrackedChangeState): TrackedChangeState;
22
+ export declare function getPendingChangesCount(state: TrackedChangeState): number;
23
+ export declare function getChangesByUser(state: TrackedChangeState, userId: string): TrackedChange[];
24
+ /**
25
+ * Render tracked changes as HTML with colored marks
26
+ */
27
+ export declare function renderTrackedChanges(html: string, changes: TrackedChange[]): string;
28
+ /**
29
+ * Extract tracked changes from HTML
30
+ */
31
+ export declare function extractTrackedChanges(html: string): TrackedChange[];
@@ -0,0 +1,106 @@
1
+ // ============================================
2
+ // Tracked Changes utilities — track insertions/deletions
3
+ // ============================================
4
+ export function createTrackedChangeState(userId, userName) {
5
+ return {
6
+ enabled: false,
7
+ changes: [],
8
+ currentUserId: userId,
9
+ currentUserName: userName,
10
+ };
11
+ }
12
+ export function addTrackedChange(state, type, content) {
13
+ const change = {
14
+ id: `tc-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
15
+ type,
16
+ userId: state.currentUserId,
17
+ userName: state.currentUserName,
18
+ timestamp: new Date().toISOString(),
19
+ content,
20
+ accepted: false,
21
+ };
22
+ return change;
23
+ }
24
+ export function acceptChange(state, changeId) {
25
+ return {
26
+ ...state,
27
+ changes: state.changes.map((c) => c.id === changeId ? { ...c, accepted: true } : c),
28
+ };
29
+ }
30
+ export function rejectChange(state, changeId) {
31
+ return {
32
+ ...state,
33
+ changes: state.changes.filter((c) => c.id !== changeId),
34
+ };
35
+ }
36
+ export function acceptAllChanges(state) {
37
+ return {
38
+ ...state,
39
+ changes: state.changes.map((c) => ({ ...c, accepted: true })),
40
+ };
41
+ }
42
+ export function rejectAllChanges(state) {
43
+ return {
44
+ ...state,
45
+ changes: [],
46
+ };
47
+ }
48
+ export function getPendingChangesCount(state) {
49
+ return state.changes.filter((c) => !c.accepted).length;
50
+ }
51
+ export function getChangesByUser(state, userId) {
52
+ return state.changes.filter((c) => c.userId === userId);
53
+ }
54
+ /**
55
+ * Render tracked changes as HTML with colored marks
56
+ */
57
+ export function renderTrackedChanges(html, changes) {
58
+ let result = html;
59
+ for (const change of changes) {
60
+ if (change.accepted)
61
+ continue;
62
+ if (change.type === 'insertion') {
63
+ // Mark insertions with green background
64
+ result = result.replace(change.content, `<span class="tracked-insertion" data-change-id="${change.id}" style="background: #dcfce7; text-decoration: none;">${change.content}</span>`);
65
+ }
66
+ else if (change.type === 'deletion') {
67
+ // Mark deletions with red strikethrough
68
+ result = result.replace(change.content, `<span class="tracked-deletion" data-change-id="${change.id}" style="background: #fee2e2; text-decoration: line-through; color: #991b1b;">${change.content}</span>`);
69
+ }
70
+ }
71
+ return result;
72
+ }
73
+ /**
74
+ * Extract tracked changes from HTML
75
+ */
76
+ export function extractTrackedChanges(html) {
77
+ const changes = [];
78
+ // Extract insertions
79
+ const insertionRegex = /<span[^>]*class="tracked-insertion"[^>]*data-change-id="([^"]*)"[^>]*>(.*?)<\/span>/gi;
80
+ let match;
81
+ while ((match = insertionRegex.exec(html)) !== null) {
82
+ changes.push({
83
+ id: match[1],
84
+ type: 'insertion',
85
+ userId: 'unknown',
86
+ userName: 'Unknown',
87
+ timestamp: new Date().toISOString(),
88
+ content: match[2],
89
+ accepted: false,
90
+ });
91
+ }
92
+ // Extract deletions
93
+ const deletionRegex = /<span[^>]*class="tracked-deletion"[^>]*data-change-id="([^"]*)"[^>]*>(.*?)<\/span>/gi;
94
+ while ((match = deletionRegex.exec(html)) !== null) {
95
+ changes.push({
96
+ id: match[1],
97
+ type: 'deletion',
98
+ userId: 'unknown',
99
+ userName: 'Unknown',
100
+ timestamp: new Date().toISOString(),
101
+ content: match[2],
102
+ accepted: false,
103
+ });
104
+ }
105
+ return changes;
106
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@intinyagroup/rich-text",
3
+ "version": "0.1.1-alpha.5",
4
+ "description": "Rich text editor component powered by TipTap",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "svelte": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "svelte-package -o dist",
18
+ "check": "echo 'no check'",
19
+ "dev": "svelte-package -o dist -w"
20
+ },
21
+ "dependencies": {
22
+ "@intinyagroup/ui": "0.1.1-alpha.5",
23
+ "@intinyagroup/grid-core": "0.1.1-alpha.5",
24
+ "@tiptap/core": "^3.22.5",
25
+ "@tiptap/starter-kit": "^3.22.5",
26
+ "@tiptap/extension-placeholder": "^3.22.5",
27
+ "@tiptap/extension-underline": "^3.22.5",
28
+ "@tiptap/extension-text-align": "^3.22.5",
29
+ "@tiptap/extension-link": "^3.22.5",
30
+ "@tiptap/extension-image": "^3.22.5",
31
+ "@tiptap/extension-code-block-lowlight": "^3.22.5",
32
+ "@tiptap/pm": "^3.22.5"
33
+ },
34
+ "devDependencies": {
35
+ "@sveltejs/package": "^2.5.8",
36
+ "svelte": "^5.55.2",
37
+ "typescript": "^6.0.2",
38
+ "@sveltejs/vite-plugin-svelte": "^5.1.1"
39
+ },
40
+ "peerDependencies": {
41
+ "svelte": "^5.0.0"
42
+ },
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/intinyagroup/ui.git",
47
+ "directory": "packages/rich-text"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }