@intinyagroup/rich-text 0.1.1-alpha.19 → 0.1.1-alpha.20

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,7 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from "svelte";
3
- import { Editor, Node, mergeAttributes } from "@tiptap/core";
3
+ import { Editor, Mark, Node, mergeAttributes } from "@tiptap/core";
4
4
  import StarterKit from "@tiptap/starter-kit";
5
+ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
6
+ import { common, createLowlight } from "lowlight";
5
7
  import Placeholder from "@tiptap/extension-placeholder";
6
8
  import Underline from "@tiptap/extension-underline";
7
9
  import TextAlign from "@tiptap/extension-text-align";
@@ -48,10 +50,15 @@
48
50
  Sparkles,
49
51
  HelpCircle,
50
52
  FileText,
53
+ Search,
54
+ Download,
55
+ Unlink,
51
56
  } from "lucide-svelte";
52
- import { Button, Separator } from "@intinyagroup/ui";
57
+ import { Button, Input, Separator } from "@intinyagroup/ui";
53
58
  import { cn } from "@intinyagroup/grid-core/utils";
54
59
 
60
+ const lowlight = createLowlight(common);
61
+
55
62
  let {
56
63
  content = "",
57
64
  placeholder = "Start writing...",
@@ -62,6 +69,8 @@
62
69
  onUpdate,
63
70
  onImageUpload,
64
71
  onOpenSubPage,
72
+ onExportHtml,
73
+ onExportMarkdown,
65
74
  }: {
66
75
  content?: string;
67
76
  placeholder?: string;
@@ -74,6 +83,10 @@
74
83
  /** Called when paste/drop provides an image file. Return a URL to insert. */
75
84
  onImageUpload?: (file: File) => Promise<string>;
76
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;
77
90
  } = $props();
78
91
 
79
92
  let editorEl: HTMLDivElement | null = null;
@@ -81,6 +94,41 @@
81
94
  let editor: Editor | null = null;
82
95
  let isActive = $state<Record<string, boolean>>({});
83
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
+ });
84
132
 
85
133
  // Slash commands state
86
134
  let showSlashMenu = $state(false);
@@ -88,6 +136,10 @@
88
136
  let slashIndex = $state(0);
89
137
  let slashMenuPos = $state({ top: 0, left: 0 });
90
138
 
139
+ let textColor = $state("#1f2937");
140
+ let highlightColor = $state("#fef08a");
141
+ let searchMatches: Array<{ from: number; to: number }> = [];
142
+
91
143
  const slashCommands = [
92
144
  {
93
145
  title: "Sub-page",
@@ -324,36 +376,6 @@
324
376
  }
325
377
  }
326
378
 
327
- const CustomYoutube = Node.create({
328
- name: "youtube",
329
- group: "block",
330
- atom: true,
331
- addAttributes() {
332
- return {
333
- src: { default: null },
334
- };
335
- },
336
- parseHTML() {
337
- return [{ tag: 'iframe[src*="youtube"]' }];
338
- },
339
- renderHTML({ HTMLAttributes }) {
340
- return [
341
- "div",
342
- {
343
- class: "video-container my-4 aspect-video rounded-xl overflow-hidden",
344
- },
345
- [
346
- "iframe",
347
- mergeAttributes(HTMLAttributes, {
348
- class: "w-full h-full border-0",
349
- allow:
350
- "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
351
- allowfullscreen: "true",
352
- }),
353
- ],
354
- ];
355
- },
356
- });
357
379
  /** SubPage block extension (Page-in-page card) */
358
380
  const SubPage = Node.create({
359
381
  name: "subpage",
@@ -452,6 +474,7 @@
452
474
  underline: e.isActive("underline"),
453
475
  strike: e.isActive("strike"),
454
476
  highlight: e.isActive("highlight"),
477
+ textColor: e.isActive("textColor"),
455
478
  h1: e.isActive("heading", { level: 1 }),
456
479
  h2: e.isActive("heading", { level: 2 }),
457
480
  h3: e.isActive("heading", { level: 3 }),
@@ -467,6 +490,115 @@
467
490
  };
468
491
  }
469
492
 
493
+ function setTextColor(color: string) {
494
+ textColor = color;
495
+ editor?.chain().focus().setMark("textColor", { color }).run();
496
+ }
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
+ }
470
602
  function toggleBold() {
471
603
  editor?.chain().focus().toggleBold().run();
472
604
  }
@@ -497,10 +629,6 @@
497
629
  function setAlign(align: "left" | "center" | "right" | "justify") {
498
630
  editor?.chain().focus().setTextAlign(align).run();
499
631
  }
500
- function setLink() {
501
- const url = window.prompt("Enter URL:");
502
- if (url) editor?.chain().focus().setLink({ href: url }).run();
503
- }
504
632
  function setImage() {
505
633
  const url = window.prompt("Enter image URL:");
506
634
  if (url) editor?.chain().focus().setImage({ src: url }).run();
@@ -552,6 +680,19 @@
552
680
  cmd.action();
553
681
  showSlashMenu = false;
554
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;
692
+ }
693
+ const url = window.prompt("Enter URL:");
694
+ if (url?.trim()) editor.chain().focus().setLink({ href: url.trim() }).run();
695
+ }
555
696
  function addColumnBefore() {
556
697
  editor?.chain().focus().addColumnBefore().run();
557
698
  }
@@ -590,9 +731,11 @@
590
731
  editor = new Editor({
591
732
  element: editorEl,
592
733
  extensions: [
593
- StarterKit,
734
+ StarterKit.configure({ codeBlock: false }),
735
+ CodeBlockLowlight.configure({ lowlight }),
594
736
  Placeholder.configure({ placeholder }),
595
737
  Underline,
738
+ TextColor,
596
739
  TextAlign.configure({ types: ["heading", "paragraph"] }),
597
740
  Link.configure({ openOnClick: false }),
598
741
  CustomImage.configure({
@@ -908,6 +1051,43 @@
908
1051
  >
909
1052
  <Highlighter class="size-3.5" />
910
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>
911
1091
  </div>
912
1092
 
913
1093
  <Separator orientation="vertical" class="h-4 mx-1.5 opacity-60" />
@@ -1048,6 +1228,46 @@
1048
1228
  >
1049
1229
  <LinkIcon class="size-3.5" />
1050
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>
1051
1271
  <Button
1052
1272
  variant="ghost"
1053
1273
  size="sm"
@@ -1094,6 +1314,39 @@
1094
1314
  {/if}
1095
1315
  </div>
1096
1316
  {/if}
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}
1097
1350
  <div
1098
1351
  bind:this={editorEl}
1099
1352
  class="rich-text-content prose prose-sm max-w-none p-5 sm:p-7 focus:outline-none"
@@ -13,6 +13,10 @@ type $$ComponentProps = {
13
13
  id: string;
14
14
  title: string;
15
15
  }) => void;
16
+ /** Called by HTML export action with current document HTML. */
17
+ onExportHtml?: (html: string) => void;
18
+ /** Called by Markdown export action with best-effort CommonMark output. */
19
+ onExportMarkdown?: (markdown: string) => void;
16
20
  };
17
21
  declare const RichTextEditor: import("svelte").Component<$$ComponentProps, {}, "">;
18
22
  type RichTextEditor = ReturnType<typeof RichTextEditor>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intinyagroup/rich-text",
3
- "version": "0.1.1-alpha.19",
3
+ "version": "0.1.1-alpha.20",
4
4
  "description": "Rich text editor component powered by TipTap",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,8 +21,8 @@
21
21
  "dev": "svelte-package -o dist -w"
22
22
  },
23
23
  "dependencies": {
24
- "@intinyagroup/grid-core": "0.1.1-alpha.19",
25
- "@intinyagroup/ui": "0.1.1-alpha.19",
24
+ "@intinyagroup/grid-core": "0.1.1-alpha.20",
25
+ "@intinyagroup/ui": "0.1.1-alpha.20",
26
26
  "@tiptap/core": "^3.22.5",
27
27
  "@tiptap/extension-bubble-menu": "^3.22.5",
28
28
  "@tiptap/extension-code-block-lowlight": "^3.22.5",
@@ -41,6 +41,7 @@
41
41
  "@tiptap/extension-underline": "^3.22.5",
42
42
  "@tiptap/pm": "^3.22.5",
43
43
  "@tiptap/starter-kit": "^3.22.5",
44
+ "lowlight": "^3.3.0",
44
45
  "lucide-svelte": "^1.0.1"
45
46
  },
46
47
  "devDependencies": {