🎉

リッチテキストエディタ(TipTap)でSvelteコンポーネントを扱う

2024/12/21に公開

はじめに

こんにちは、株式会社Liquitousのエンジニアのかずうみです。

本日はSvelte Advent Calendar 2024の16日目ということで、任意のSvelteコンポーネントをリッチテキストエディタのTipTapで扱うために、svelte-tiptapというライブラリを活用します。

レポジトリ
https://github.com/KazuumiN/svelte-tiptap-demo

環境設定

Svelte 5でSvelteKitのプロジェクトを立ち上げ、必要なパッケージをインストールします。

  1. プロジェクトを立ち上げる。
    • npx sv create
      • お好みで構いません。今回は見栄えのためにTailwind CSSと、Typographyプラグインをインストールしています。
  2. リッチテキスト(TipTap)に関するパッケージをインストールする。
    • pnpm add @tiptap/core @tiptap/starter-kit svelte-tiptap

リッチテキストエディタをセットアップ

Editor.svelteを作成し、+page.svelteから読み込みます。

src/lib/Editor.svelte
<script lang="ts">
	import { onDestroy, onMount } from 'svelte';
	import type { Content } from '@tiptap/core';
	import { Editor } from '@tiptap/core';
	import StarterKit from '@tiptap/starter-kit';

	let editor = $state<Editor>();
	let element = $state<HTMLElement>();

	let {
		content = $bindable(null)
	}: {
		content: Content;
	} = $props();

	onMount(() => {
		editor = new Editor({
			element,
			content,
			extensions: [
                StarterKit,
            ],
			editorProps: {
				attributes: {
					// @tailwindcss/typography をインストールしている場合に使えるクラス
					class: 'prose max-w-full'
				}
			},
			onTransaction: ({ editor }) => {
				content = editor.getJSON();
			}
		});
	});

	onDestroy(() => {
		if (editor) {
			editor.destroy();
		}
	});
</script>

<div bind:this={element} class="border-2"></div>
src/routes/+page.svelte
<script lang="ts">
	import Editor from '$lib/Editor.svelte';
	import type { Content } from '@tiptap/core';
	let content = $state<Content>(null);
</script>

<Editor bind:content />

この状態で開発サーバーにアクセスすると、次のように最低限リッチテキストエディタとして使えるようになります。スタイルの切り替えボタンは用意しませんでしたが、既にマークダウン記法やショートカットキーが有効になっています。

簡単なSvelteコンポーネントを埋め込んでみる

それでは、自分で作成したSvelteコンポーネントをエディタで扱えるようにしてみます。

ここでは、非常に簡単なボタンコンポーネントにします。
コンポーネント内では、attributesに変数を置いたり更新したりできます。

src/lib/Counter.svelte
<script lang="ts">
	import type { NodeViewProps } from '@tiptap/core';
	import { NodeViewWrapper } from 'svelte-tiptap';

	let { node, updateAttributes }: NodeViewProps = $props();

	const handleClick = () => {
		// クリックしたときに、属性の count を1増やす
		updateAttributes({ count: node.attrs.count + 1 });
	};
</script>

<NodeViewWrapper class="border-2 border-green-400">
	<span>スベルトコンポーネント</span>
	<div>
		<button onclick={handleClick} type="button">
			クリック数: {node.attrs.count}回
		</button>
	</div>
</NodeViewWrapper>

SvelteExtension.tsで、このコンポーネントをExtensionのノードにします。

src/lib/SvelteExtension.ts
import { Node, mergeAttributes } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';

import CounterComponent from './Counter.svelte';

export const SvelteCounterExtension = Node.create({
	name: 'SvelteCounterComponent',
	group: 'block',
	atom: true,
	draggable: true,
	inline: false,

	addAttributes() {
		return {
			count: {
				default: 0,
			},
		};
	},

	parseHTML() {
		return [{ tag: 'svelte-counter-component' }];
	},

	renderHTML({ HTMLAttributes }) {
		return ['svelte-counter-component', mergeAttributes(HTMLAttributes)];
	},

	addNodeView() {
		return SvelteNodeViewRenderer(CounterComponent);
	},
});

最後に、Editor.svelteでこの拡張機能を読み込み、追加できるようにします。

src/lib/Editor.svelte
<script lang="ts">
	import { onDestroy, onMount } from 'svelte';
	import type { Content } from '@tiptap/core';
	import { Editor } from '@tiptap/core';
	import StarterKit from '@tiptap/starter-kit';
	import { SvelteCounterExtension } from './SvelteExtension';

	let editor = $state<Editor>();
	let element = $state<HTMLElement>();

	let {
		content = $bindable(null)
	}: {
		content: Content;
	} = $props();

	onMount(() => {
		editor = new Editor({
			element,
			content,
			extensions: [
                StarterKit,
                SvelteCounterExtension, // Counter拡張機能として追加
            ],
			editorProps: {
				attributes: {
					class: 'prose max-w-full'
				}
			},
			onTransaction: (transaction) => {
                editor = transaction.editor;
                content = editor.getJSON();
			}
		});
	});

	onDestroy(() => {
		if (editor) {
			editor.destroy();
		}
	});

    const addCounter = () => {
        if (!editor) return;
        // editorインスタンスにSvelteCounterComponentを挿入
		editor
			.chain()
			.insertContent({
				type: 'SvelteCounterComponent',
			})
			.focus()
			.run();
	}
</script>

<button onclick={addCounter} class="bg-blue-500 text-white px-4 py-2 rounded">
    カウンターを追加
</button>

<div bind:this={element} class="border-2"></div>

このように、自分で作成したボタンコンポーネントを読み込めるようになります。

最後に

シンプルな例でしたが、以上がTipTap上でSvelteコンポーネントを扱う方法でした!
TipTapは非常に便利な拡張機能が多く用意されていますが、Svelteで好きに書けるといっそう自由度が高まります。

ぜひ使ってみてください!

Discussion