Skip to content

Instantly share code, notes, and snippets.

@rudiv
Created November 17, 2024 20:48
Show Gist options
  • Save rudiv/e44fb196bc8c9f215ae0c5993a245d3a to your computer and use it in GitHub Desktop.
Save rudiv/e44fb196bc8c9f215ae0c5993a245d3a to your computer and use it in GitHub Desktop.
shadcn-svelte tiptap Svelte 5 Rich Text Editor
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Editor, type Extensions } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import CharacterCount from "@tiptap/extension-character-count";
import { debounce } from "$lib/utils";
import { Toggle } from "$lib/components/ui/toggle";
import IconBold from "@tabler/icons-svelte/icons/bold";
import IconItalic from "@tabler/icons-svelte/icons/italic";
import IconUnderline from "@tabler/icons-svelte/icons/underline";
import IconH1 from "@tabler/icons-svelte/icons/h-1";
import IconH2 from "@tabler/icons-svelte/icons/h-2";
import IconH3 from "@tabler/icons-svelte/icons/h-3";
import IconPara from "@tabler/icons-svelte/icons/pilcrow";
import IconUl from "@tabler/icons-svelte/icons/list";
import IconOl from "@tabler/icons-svelte/icons/list-numbers";
import { Separator } from "$lib/components/ui/separator";
let {
initialContent,
json = $bindable(),
onJsonUpdate,
textLimit
}: {
initialContent?: string | undefined;
json?: any;
onJsonUpdate?: (json: string) => void;
textLimit?: number;
} = $props();
let element: HTMLDivElement;
let editor: Editor | undefined = $state();
const debouncedBindUpdater = debounce(() => {
if (editor) {
json = editor!.getJSON();
if (onJsonUpdate) {
onJsonUpdate(json);
}
}
}, 750);
let editorState = $state({
bold: false,
italic: false,
underline: false,
h1: false,
h2: false,
h3: false,
para: false,
ul: false,
ol: false,
characterCount: 0
});
onMount(() => {
let extensions: Extensions = [StarterKit, Underline];
if (textLimit) {
extensions.push(CharacterCount.configure({ limit: textLimit }));
}
editor = new Editor({
element: element,
extensions: extensions,
content: initialContent ?? "",
onCreate: () => {
json = editor?.getJSON();
},
onTransaction: ({ editor: ins }: { editor: Editor }) => {
editorState.bold = ins.isActive("bold");
editorState.italic = ins.isActive("italic");
editorState.underline = ins.isActive("underline");
editorState.h1 = ins.isActive("heading", { level: 1 });
editorState.h2 = ins.isActive("heading", { level: 2 });
editorState.h3 = ins.isActive("heading", { level: 3 });
editorState.para = ins.isActive("paragraph");
editorState.ul = ins.isActive("bullet_list");
editorState.ol = ins.isActive("ordered_list");
editorState.characterCount = editor?.storage.characterCount.characters() ?? 0;
},
onUpdate: () => {
debouncedBindUpdater();
}
});
});
onDestroy(() => {
if (editor) {
editor.destroy();
}
});
</script>
<div class="custom-rte">
<div bind:this={element} class="prose prose-sm prose-h1:text-xl prose-h2:text-lg max-w-none">
</div>
{#if editor}
<div class="flex items-center bg-gray-100 p-2">
<div class="flex flex-1 items-center gap-0.5">
<Toggle
size="sm"
bind:pressed={editorState.bold}
onPressedChange={() => editor!.chain().focus().toggleBold().run()}>
<IconBold class="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
bind:pressed={editorState.italic}
onPressedChange={() => editor!.chain().focus().toggleItalic().run()}>
<IconItalic class="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
bind:pressed={editorState.underline}
onPressedChange={() => editor!.chain().focus().toggleUnderline().run()}>
<IconUnderline class="h-4 w-4" />
</Toggle>
<Separator orientation="vertical" class="mx-2 h-6 bg-gray-300" />
<Toggle
size="sm"
bind:pressed={editorState.para}
onPressedChange={() => editor!.chain().focus().setParagraph().run()}>
<IconPara class="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
bind:pressed={editorState.h1}
onPressedChange={() => editor!.chain().focus().toggleHeading({ level: 1 }).run()}>
<IconH1 class="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
bind:pressed={editorState.h2}
onPressedChange={() => editor!.chain().focus().toggleHeading({ level: 2 }).run()}>
<IconH2 class="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
bind:pressed={editorState.h3}
onPressedChange={() => editor!.chain().focus().toggleHeading({ level: 3 }).run()}>
<IconH3 class="h-4 w-4" />
</Toggle>
<Separator orientation="vertical" class="mx-2 h-6 bg-gray-300" />
<Toggle
size="sm"
bind:pressed={editorState.ul}
onPressedChange={() => editor!.chain().focus().toggleBulletList().run()}>
<IconUl class="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
bind:pressed={editorState.ol}
onPressedChange={() => editor!.chain().focus().toggleOrderedList().run()}>
<IconOl class="h-4 w-4" />
</Toggle>
</div>
<div>
{#if textLimit}
<p class="ml-2 text-xs text-gray-500">
{editorState.characterCount}/{textLimit}
</p>
{/if}
</div>
</div>
{:else}
<p class="text-muted-foreground text-xs">Loading editor...</p>
{/if}
</div>
<style lang="postcss">
.custom-rte {
@apply border-input rounded-md border bg-white;
}
:global(.custom-rte .tiptap) {
@apply max-h-[12rem] min-h-[6rem] overflow-y-scroll bg-white p-4;
@apply ring-offset-background focus-visible:ring-ring rounded-t-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
:global(.custom-rte .tiptap :first-child) {
@apply mt-0;
}
:global(.custom-rte .tiptap :last-child) {
@apply mb-0;
}
</style>
export function debounce<Params extends any[]>(func: (...args: Params) => any, timeout: number): (...args: Params) => void {
let timer: number;
return (...args: Params) => {
clearTimeout(timer);
timer = window.setTimeout(() => {
func(...args)
}, timeout);
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment