シンプルで抜群の使い心地!Novelでつくるオウンドメディア

シンプルで抜群の使い心地!Novelでつくるオウンドメディア

はじめに ― この記事で書くこと

前回のコラム「Novelというブロックエディタでコラム投稿を効率化!」では、Novelというエディタの概要と、なぜ導入したかを紹介しました。今回はその続編として、実際の導入手順、標準機能の使い方、そして当サイトで独自に追加したカスタム機能について、コードを交えながら具体的に解説します。

「Novelって何ができるの?」「自分のサイトに組み込むにはどうすればいい?」という疑問に、実例で答える記事です。

Novelとは ― 30秒でわかるまとめ

Novelは、Tiptap(リッチテキストエンジン)をベースにしたReact向けのエディタコンポーネントです。Notionのような操作感をnpmパッケージとして提供しており、Next.jsプロジェクトにそのまま組み込めます。

  • Tiptap: GitHubスター33,000以上、npm月間1,280万ダウンロード。LinkedIn、Substack、Axiosなどが採用

  • Novel: Tiptapの上にNotionライクなUIを被せたパッケージ。GitHubスター15,000以上

海外では広く使われていますが、日本語の情報はほぼ皆無。この記事が数少ない日本語での実装解説になるはずです。

導入手順 ― Next.jsプロジェクトへの組み込み

1. パッケージのインストール


npm install novel

Novelをインストールすると、Tiptap本体と主要な拡張機能が一括で入ります。個別にTiptapのパッケージを追加する必要はありません。

2. 拡張機能の設定

Novelの中核は「拡張機能(Extensions)」の組み合わせです。どの機能を有効にするかを設定ファイルにまとめます。当サイトでは以下のように構成しています。


import {

  TiptapLink, TiptapUnderline, UpdatedImage,

  TaskList, TaskItem, HorizontalRule,

  StarterKit, Placeholder, Color, TextStyle,

  GlobalDragHandle, CodeBlockLowlight, Youtube,

} from 'novel';

import { Node } from '@tiptap/core';

import { common, createLowlight } from 'lowlight';



const lowlight = createLowlight(common);



const placeholder = Placeholder.configure({

  placeholder: ({ node }) => {

    if (node.type.name === 'heading') {

      return `見出し${node.attrs.level ?? ''}`;

    }

    return '/ でブロック挿入(例: /h1, /list)';

  },

  includeChildren: true,

});



const starterKit = StarterKit.configure({

  heading: { levels: [1, 2, 3] },

  bulletList: { HTMLAttributes: { class: 'list-disc list-outside' } },

  orderedList: { HTMLAttributes: { class: 'list-decimal list-outside' } },

  blockquote: { HTMLAttributes: { class: 'border-l-4 border-blue-500' } },

  codeBlock: false, // CodeBlockLowlightで置換

});



const codeBlockLowlight = CodeBlockLowlight.configure({

  lowlight,

  defaultLanguage: null, // 自動検出

});

ポイントは、Placeholderを日本語化していることと、標準のcodeBlockを無効にしてCodeBlockLowlight(シンタックスハイライト付き)に差し替えていることです。

3. エディタコンポーネントの配置

Novelが提供するEditorRootEditorContentを使って、エディタ本体を配置します。


import {

  EditorRoot, EditorContent, EditorCommand,

  EditorCommandItem, EditorCommandEmpty, EditorCommandList,

  EditorBubble, EditorBubbleItem,

  handleCommandNavigation, handleImageDrop, handleImagePaste,

  ImageResizer,

} from 'novel';



<EditorRoot>

  <EditorContent

    initialContent={parsedContent}

    extensions={extensions}

    className="border border-gray-300 rounded-lg bg-white"

    editorProps={{

      handleDOMEvents: {

        keydown: (_view, event) => handleCommandNavigation(event),

      },

      handlePaste: (view, event) =>

        handleImagePaste(view, event, uploadFn),

      handleDrop: (view, event, _slice, moved) =>

        handleImageDrop(view, event, moved, uploadFn),

    }}

    onUpdate={({ editor }) => {

      onChange(editor.getHTML());

      onJsonChange?.(editor.getJSON());

    }}

    slotAfter={<ImageResizer />}

  >

    {/* スラッシュコマンドメニュー */}

    <EditorCommand>

      <EditorCommandEmpty>コマンドが見つかりません</EditorCommandEmpty>

      <EditorCommandList>

        {suggestionItems.map((item) => (

          <EditorCommandItem key={item.title} value={item.title}

            onCommand={(val) => item.command?.(val)}>

            {item.icon}

            <p>{item.title}</p>

          </EditorCommandItem>

        ))}

      </EditorCommandList>

    </EditorCommand>



    {/* バブルメニュー(テキスト選択時) */}

    <EditorBubble>

      <EditorBubbleItem onSelect={(editor) =>

        editor.chain().focus().toggleBold().run()}>

        <Bold />

      </EditorBubbleItem>

      {/* ...他の装飾ボタン */}

    </EditorBubble>

  </EditorContent>

</EditorRoot>

この構造が基本形です。EditorContentの中にEditorCommand(スラッシュコマンド)とEditorBubble(選択時のツールバー)を入れ子にする設計になっています。

標準で使える機能

パッケージを入れて拡張機能を設定するだけで、以下の機能が使えます。

  • 見出し(H1〜H3)

  • 箇条書き・番号付きリスト

  • チェックリスト(タスクリスト)

  • 引用ブロック

  • コードブロック(シンタックスハイライト付き)

  • 太字・斜体・下線・取り消し線

  • 水平線

  • リンクの挿入

  • 画像の挿入(ドラッグ&ドロップ対応)

  • YouTube動画の埋め込み

  • ブロック単位のドラッグ&ドロップ並び替え

  • スラッシュコマンド/キーでブロックを呼び出す)

これだけ揃っていれば、一般的なブログ記事の作成には十分です。

 画像やYoutubeの埋込ブロックも実装できます


スラッシュコマンド ― Notion風の操作感

Novelの特徴的な機能が「スラッシュコマンド」です。エディタ上で/を入力すると、挿入可能なブロックの一覧がポップアップ表示されます。

当サイトでは、コマンドメニューの項目名と説明をすべて日本語化しています。


import { Command, createSuggestionItems, renderItems } from 'novel';



export const suggestionItems = createSuggestionItems([

  {

    title: '見出し 1',

    description: '大見出し',

    searchTerms: ['heading', 'h1'],

    icon: <span className="font-bold text-xl">H1</span>,

    command: ({ editor, range }) => {

      editor.chain().focus().deleteRange(range)

        .setHeading({ level: 1 }).run();

    },

  },

  {

    title: '箇条書き',

    description: '箇条書きリスト',

    searchTerms: ['bullet', 'list'],

    icon: <span></span>,

    command: ({ editor, range }) => {

      editor.chain().focus().deleteRange(range)

        .toggleBulletList().run();

    },

  },

  // ...他のコマンド

]);



export const slashCommand = Command.configure({

  suggestion: {

    items: () => suggestionItems,

    render: renderItems,

  },

});

searchTermsに英語のキーワードも入れているので、/h1/listのように英語でも呼び出せます。日本語と英語のどちらでも使える設計です。

ここからが本番 ― 独自に追加したカスタム機能

Novelの標準機能だけでは足りない部分を、独自に実装しています。ここからが、このサイトならではの話です。

1. リンクカード(OGプレビュー)

URLを入力すると、そのページのタイトル・説明文・サムネイル画像を自動取得して、カード形式で表示する機能です。Novelの標準にはありません。

仕組みとしては、TiptapのカスタムノードとしてlinkCardを定義し、バックエンドにOGタグ取得用のAPIを用意しています。


const linkCard = Node.create({

  name: 'linkCard',

  group: 'block',

  atom: true,

  selectable: true,

  draggable: true,

  addAttributes() {

    return {

      url: { default: '' },

      title: { default: '' },

      description: { default: '' },

      image: { default: '' },

      site: { default: '' },

      size: { default: 'M' },

    };

  },

});

スラッシュコマンドから/リンクカードで呼び出すと、URLの入力を求められます。入力後、バックエンドのAPIが対象ページのHTMLを取得し、OGタグ(og:titleog:descriptionog:imageog:site_name)を抽出してカードを生成します。


{

  title: 'リンクカード',

  description: 'リンクをカード表示',

  searchTerms: ['link', 'card', 'embed', 'リンク'],

  icon: <span>🔗</span>,

  command: async ({ editor, range }) => {

    const url = window.prompt('リンクカード化するURLを入力してください');

    if (!url) { editor.chain().focus().deleteRange(range).run(); return; }



    const sizeInput = (window.prompt('サイズを選択: S/M/L', 'M') || 'M').toUpperCase();

    const size = sizeInput === 'S' || sizeInput === 'L' ? sizeInput : 'M';



    const res = await fetch(`/api/link-preview?url=${encodeURIComponent(url)}`);

    const json = await res.json();

    const data = json.data;



    editor.chain().focus().deleteRange(range)

      .insertContent({

        type: 'linkCard',

        attrs: {

          url: data.url,

          title: data.title || data.url,

          description: data.description || '',

          image: data.image_url || '',

          site: data.site_name || '',

          size,

        },

      }).run();

  },

}

さらに、取得したOGデータはデータベースにキャッシュしています。同じURLのカードを再度作成する際は、キャッシュから即座に返すため高速です。ただし、タイトルや画像が不完全なデータの場合は再取得する仕組みも入れています。

カードのサイズはS・M・Lの3段階から選べるようにしました。記事のレイアウトに合わせて使い分けられます。

2. 画像プレースホルダー

Novelの標準では、画像の挿入はファイル選択ダイアログかドラッグ&ドロップです。当サイトでは、まず「ここに画像が入る」というプレースホルダーを配置し、あとからクリックやドロップで実画像に差し替える方式を採用しました。


const PLACEHOLDER_MARKER = '__img_placeholder__';

const PLACEHOLDER_SVG = `data:image/svg+xml,${encodeURIComponent(

  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 160">' +

  '<rect x="1" y="1" width="598" height="158" rx="8" fill="#f9fafb" ' +

  'stroke="#d1d5db" stroke-width="2" stroke-dasharray="8 4"/>' +

  '<text x="300" y="100" text-anchor="middle" font-size="13" ' +

  'fill="#9ca3af">画像をアップロード</text>' +

  '</svg>'

)}`;

スラッシュコマンドで/画像を選ぶと、このSVGプレースホルダーが挿入されます。記事の構成を先に考えてから、あとで画像を差し込む。この流れが自然にできるようになりました。

プレースホルダーをクリックするとファイル選択ダイアログが開き、画像をドロップすることもできます。アップロードが完了すると、プレースホルダーが実画像に自動で置き換わります。アップロードに失敗した場合は、プレースホルダーが自動削除される安全設計です。

3. 画像の自動圧縮

アップロードされた画像は、クラウドストレージに送る前に自動で圧縮されます。


export async function compressImage(file: File): Promise<File> {

  return new Promise((resolve, reject) => {

    const img = new window.Image();

    img.src = URL.createObjectURL(file);



    img.onload = () => {

      const canvas = document.createElement('canvas');

      const ctx = canvas.getContext('2d');



      const maxWidth = 1920;

      const maxHeight = 1080;

      let width = img.width;

      let height = img.height;



      if (width > maxWidth) {

        height = (maxWidth * height) / width;

        width = maxWidth;

      }

      if (height > maxHeight) {

        width = (maxHeight * width) / height;

        height = maxHeight;

      }



      canvas.width = width;

      canvas.height = height;

      ctx?.drawImage(img, 0, 0, width, height);



      canvas.toBlob(

        (blob) => {

          if (blob) {

            resolve(new File([blob], `${file.name}.jpg`, {

              type: 'image/jpeg', lastModified: Date.now(),

            }));

          }

        },

        'image/jpeg',

        0.8

      );

      URL.revokeObjectURL(img.src);

    };

  });

}

最大1920×1080px、JPEG品質0.8に統一しています。スマートフォンで撮った高解像度の写真をそのままドロップしても、自動的に適切なサイズに変換されるため、ストレージ容量の節約とページ表示速度の両方に効いています。ブラウザのCanvas APIだけで完結するので、サーバー側に圧縮処理を持つ必要がありません。

4. テキストペーストの改行保持

地味ですが重要なカスタマイズです。Tiptapの標準では、テキストを貼り付けたときに改行が失われることがあります。当サイトでは、改行を段落として保持し、空行もそのまま残す処理を入れています。


handlePaste: (view, event) => {

  if (view.state.selection.$from.parent.type.name === 'codeBlock') {

    return false; // コードブロック内はそのまま

  }



  const text = event.clipboardData?.getData('text/plain');

  if (text) {

    event.preventDefault();

    const lines = text.split('\n');

    if (lines.length > 1) {

      const nodes = lines.map(line =>

        line.trim() === ''

          ? { type: 'paragraph' }

          : { type: 'paragraph', content: [{ type: 'text', text: line }] }

      );

      editor.commands.insertContent(nodes);

      return true;

    }

  }

  return false;

},

メモ帳やメールからテキストをコピーして貼り付ける場面は多いので、この処理があるとないとでは使い勝手がかなり変わります。

5. Markdown変換機能

エディタ内にMarkdown形式で書いたテキストを、ワンクリックでリッチテキストに変換する機能も実装しています。先頭に# タイトルがあれば、それを記事タイトルとして抽出し、本文はリッチテキストに変換します。

下書きをMarkdownで書いてからリッチテキストに変換する、という使い方ができるため、Markdownに慣れている人にも使いやすい設計です。

6. 画像ギャラリー連携

過去にアップロードした画像を一覧表示し、再利用できる機能です。クラウドストレージから画像の一覧を取得して、サムネイルとして表示します。選択した画像は本文への挿入、またはサムネイル画像としての設定に使えます。

同じ画像を何度もアップロードする手間がなくなり、ストレージの重複も防げます。

データの保存 ― JSON形式の選択

エディタで作成したコンテンツは、JSON形式で保存しています。見出し、段落、画像、リストといったブロックの情報が構造化されたデータとして残ります。

表示側では、このJSONを安全なHTMLに変換する軽量レンダラーを自前で実装しています。XSS対策として、許可したノードのみを明示的に出力する方式を採用しました。


function renderNode(node: JSONNode): string {

  if (node.type === 'text') return renderTextNode(node);



  switch (node.type) {

    case 'paragraph':

      return `<p>${renderChildren(node.content)}</p>`;

    case 'heading': {

      const level = Math.min(6, Math.max(1, Number(node.attrs?.level ?? 1)));

      return `<h${level}>${renderChildren(node.content)}</h${level}>`;

    }

    case 'codeBlock': {

      const codeText = extractText(node.content);

      const tree = language

        ? lowlight.highlight(language, codeText)

        : lowlight.highlightAuto(codeText);

      return `<pre><code>${renderHast(tree)}</code></pre>`;

    }

    case 'linkCard': {

      // OGプレビュー付きカードのHTML生成

      // ...

    }

    // 他のノードタイプも同様に処理

  }

}

HTML文字列として保存する方法もありますが、JSON形式にしたことで、将来エディタを入れ替えた際のデータ移行や、表示側のデザイン変更が容易になります。

標準とカスタムの境界線

ここまで紹介した機能を整理すると、以下のようになります。

Novelの標準機能(設定のみ)

  • 見出し、リスト、引用、コードブロック、画像挿入、YouTube埋め込み

  • スラッシュコマンド、バブルメニュー、ドラッグ&ドロップ

当サイトの独自実装

  • リンクカード(OGプレビュー取得API含む)

  • 画像プレースホルダー方式

  • 画像の自動圧縮

  • テキストペーストの改行保持

  • Markdown→リッチテキスト変換

  • 画像ギャラリー連携

  • JSONレンダラー(XSS対策付き)

  • UIの完全日本語化

Novelは「エディタの土台」を提供してくれるパッケージです。その上に、自分たちの業務に合った機能を積み上げていく。この「土台+カスタム」の構造が、既成品のCMSにはない柔軟性を生んでいます。

おわりに

Novelは、日本ではまだほとんど知られていないエディタです。情報が少ない分、導入時に手探りになる部分はあります。ただ、一度組み込んでしまえば、Notionに近い書き味が自分のサイトの中で手に入ります。

まさにこの記事自体が、Novelエディタで作成・公開されている実例です。

WordPressの次を考えている方、Next.jsで独自のサイトやCMSを構築している方にとって、Novelは検討する価値のある選択肢だと思います。
難しい実装ではありますが、この記事を参考にAIエディターを使って、ぜひ挑戦してみてください!