メインコンテンツまでスキップ

「React」タグの記事が6件件あります

全てのタグを見る

· 約13分
藤崎 拓

はじめに

初めまして、株式会社リーディングマークで1on1のプロダクト開発を担当しているエンジニアの藤崎です。

今夏、新しいプロダクトの開発に携わりました。

そのプロダクトでは、長い文章を入力するフォームがあります。

その課題として、長時間のフォーム入力中にデータが消失するリスクが懸念されていたので、自動保存機能をつけるという要件が上がってきました。 Webアプリケーションでフォーム入力中にデータが消えてしまう体験は、ユーザーにとって大きなストレスです。

本記事では、自動保存機能を実装するためのアプローチや工夫した点について解説してみました!

環境情報

  • Ruby 3.3.5
  • Rails 7.0.8.6
  • Node.js 20
  • React 18.2.0
  • TypeScript 5.0.4

課題:自動保存とバリデーションの両立のジレンマ

自動保存機能を実装するにあたって、「保存」と「バリデーション」のジレンマが存在します。

例えば、googleフォームのようなアンケート回答システムを考えてみましょう。

回答テーブルに対して、自動保存機能をつけたいです。

求められるバリデーションとしては、必須回答(空文字を許容しない)、文字数制限10000文字などが想定できます。

この時、自動保存機能を実装するには、以下の2つが両立する必要があります。

  1. 自動で保存が実行されること ― ユーザーの操作なしに、バックグラウンドで保存が走る
  2. データが正しく保存されること ― 不正なデータがDBに入り込まない

一見当たり前に思えるこの2つですが、実際には両立が困難です。

なぜ難しいのか

バリデーションを設定した場合、入力途中のデータは未入力などにより、バリデーションエラーとなる可能性があります。そうなると、自動保存の最大の恩恵である「勝手に自動で保存してくれる」という体験が損なわれてしまいます。

かといって、バリデーションを外してしまうと、無制限の文字数や不正なデータがそのまま保存され、DBを破壊するリスクを抱えることになります。

結論、解決策は以下の通りです。

  1. テーブル分割

    自動保存用の下書きテーブルと、確定保存用の清書テーブルの2つのテーブルを用意する。

  2. 下書きテーブルへの自動保存

    バリデーションをなるべく最低限にする。今回の例で言うと、入力必須にはしないが、DB破壊リスクを防ぐための文字数制限だけ設定する。

    また、文字数制限超過のバリデーションエラーの場合、フロントでエラーを表示させ、保存できる入力箇所だけを保存する

  3. 清書テーブルへの保存

    入力完了後、保存ボタンからユーザに手動保存してもらう。ここではバリデーションを完全なものにする。バリデーションエラーが発生した場合は、ページ上にエラーを表示する

このあと詳細を記載します。

解決策:下書きテーブルの分離

下書き用のテーブルと清書用のテーブルを分離します。

下書きテーブル清書テーブル
保存タイミング入力変更後 1秒 debounce で自動保存送信ボタン押下時
バリデーション文字数上限のみ(10,000文字) contentは NULL許容文字数上限(10,000文字) contentは NOT NULL
目的データ損失防止 途中離脱しても安心 ブラウザクラッシュ対策正式データとして確定
CREATE TABLE answers (
id BIGSERIAL PRIMARY KEY,
question_id BIGINT NOT NULL REFERENCES questions(id),
user_id BIGINT NOT NULL,
content TEXT NOT NULL CHECK (char_length(content) >= 10), -- 必須 & 最低10文字
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(question_id, user_id)
);

ER図

image.png

下書きデータのcontentにはバリデーションを設定していませんが、 清書データのcontentにはバリデーションを設定しています。 これによって、入力中はユーザーの入力を寛容に受け入れることができます。

ただ、テーブルが分かれることによって、様々なことに考慮しなければなりませんでした。

フロントエンド工夫点1:Debounce処理による自動保存

  const triggerAutoUpdate = useCallback(
(onboarding: Onboarding) => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}

autoSaveTimeoutRef.current = setTimeout(async () => {
await saveOnboarding(onboarding);
}, debounceMs);
},
[saveOnboarding, debounceMs]
);

ユーザーが入力操作を行いフォームの中身が変化するたび、 triggerAutoUpdate が呼ばれるので、連続入力中の過度なAPIリクエストを防止するようにしました。

  • 既存のタイマーがあればクリアし、新しいタイマーをセット
  • 最後の入力から debounceMs(1000ms)経過後にAPIリクエストを送信

フロントエンド工夫点2:入力変更時のハンドラ

  const handleBasicInfoResponseChange = useCallback(
(key: keyof NewBasicInfoResponse, value: string) => {
// ...バリデーションエラーのクリア...

setBasicInfoResponse((prev) => {
const updated = { ...prev, [key]: value };
triggerAutoUpdate(buildOnboardingData({ basicInfoResponse: updated }));
return updated;
});
},
[triggerAutoUpdate, buildOnboardingData, validationErrorsState.basicInfoErrors]
);

状態更新と同時に triggerAutoUpdate を呼び出し、全ての入力項目をまとめて buildOnboardingData でオブジェクト化

これによって、setState のコールバック内で updated を作成し、その場でAPIに渡すので、100%最新の値が送信されることが保証されます。

フロントエンド工夫点3:送信前の強制保存

  const forceSave = useCallback(
async (onboarding: Onboarding) => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
autoSaveTimeoutRef.current = null;
}
await saveOnboarding(onboarding);
},
[saveOnboarding]
);

入力して、すぐに清書ボタンがクリックされてしまうと、入力中のものは更新インターバルが1秒あるので、その間に入力された値が反映されずに、清書されてしまうことがあります。

これを懸念して、フォーム送信時に forceSave を使用しました。

バックエンド工夫点1:清書保存のためのサービスクラス

DraftOnboardingFinalizer

  def finalize
result = Onboarding::DraftOnboardingFinalizer.new(employee: current_user, program: program).call
...
end


class Onboarding::DraftOnboardingFinalizer
Result = Data.define(:success?, :errors)

def initialize(employee:, program:)
@program = program
@employee = employee
@errors = []
end

def call
check_validations_for_draft_answers

if errors.empty?
finalize_onboarding
Result.new(success?: true, errors: {})
else
formatted_errors = format_errors
Result.new(success?: false, errors: formatted_errors)
end
end

def check_validations_for_draft_answers
return if draft_answers_response.valid?

draft_answers.errors.each do |error|
errors << {
type: :draft_answers,
field: error.attribute.to_s,
message: error.full_message
}
end
end

...
end

保存処理はサービスクラスに切り分けています。

draftデータが存在しているか、清書可能なデータ状態か、部分的に保存が成功したのかどうかなど、構造体resultとして情報を返しています。

バックエンド工夫点2:アクセス制御


def answer_finalized?(employee_id)
# 清書レコードが存在しているか
end

def onboarding_auto_saved?(employee_id)
# draftレコードが存在しているか
end

また、自動保存の状態は3パターンに分かれていて、それらによる画面アクセス制御も行いました。パターンは以下のように分かれます。

  • パターン1:自動保存が一度もされていないパターン
  • パターン2:自動保存が少なくとも1度はされているが、清書が未完了のパターン
  • パターン3:自動保存が少なくとも1度以上されていて、清書が完了しているパターン

自動保存を実現するためにはテーブルをわけ、それのユーザの入力状態によって、各機能のアクセス制御も分岐するという点はよく考えておく必要があるなと実感しました。

application_controller.rb

  # パターン1(未回答)でアクセスできる画面
ACCESSABLE_UNSAVED_PATHS = [
'/',
].freeze

# パターン2(回答中)でアクセスできる画面
ACCESSABLE_ANSWER_IN_PROGRESS_PATHS = [
'/',
// ...
].freeze

# パターン3(回答済)でアクセスできる画面
ACCESSABLE_ANSWER_COMPLETED_PATHS = [
'/',
// ...
].freeze

良くできたこと

  • アクセス制御の保守性

    設計段階では、各画面のアクセス制御を各controllerのbefore_actionで実装する予定でした。しかしその方法だと、アクセス制御のエラーハンドリングを、各controllerで処理することになるため、アクセス制御の全体像を見通すことができなくなってしまいます。

    そうなると、機能の追加時などに考慮漏れなどが発生する可能性が高まったり、キャッチアップの負荷が増加したりと、保守性が悪化すると考えました。

    そこで、「バックエンド工夫点2:アクセス制御」で記述したapplication_controllerに処理をまとめる方法で、解決しました。

    controllerのbefore_actionで、そのcontroller特有のエラーハンドリングをすることは良いですが、機能全体に共通するような処理は、application_controllerに切り出すことが重要であることを学びました。

    今後の開発でもここは留意していきたいなと考えております!

もっと良くできたこと

  • オフライン対応

    現状ではオンライン前提となっているので、ネットワーク切断時にlocalStorageに一時保存しておき、オンライン復帰後に同期する仕組みがあると更に便利になるかなと思っています。

  • 自動保存時の通知

    自動保存が実行されたタイミングで、「保存されました」と言うような旨の通知がポップされると、ユーザー視点自動保存がされたことが認知できて、安心して入力作業を進められるようになると思います。

おわりに

本記事では、プロダクトに自動保存機能を持たせたい場合の弊社で実装した試みをまとめてみました。

今までの自分の開発経験としては、自動保存は初めてで、最初は全く実装イメージが沸いていませんでした。チーム内でどう実現するのが良いか、じっくり皆で詰めていった結果、今の自信を持てる形が実現できたと思っています。

以上になります!

今後とも、チームで協力してより良いプロダクトを作っていけたらと考えています!

· 約8分
山本

こんにちは。サービス開発部プロダクトエンジニアの山本です。

今回は16タイプマッピングという機能開発において、バックエンドを介さずフロントエンドのみでPDF生成を実装した経験についてお話しします。

PDFを生成する場合、一般的にバックエンドを介して処理を行いますが、今回の開発ではバックエンドを使わずフロントエンドのみで実装する必要がありました。

既存のReactコンポーネントをそのままPDF化したかったためです。本記事では、html2canvasとjsPDFを活用し、フロントエンドのみでPDF生成を実装した事例について紹介します。

背景

16type16type分布図

ミキワメ適性検査では16タイプマッピングという機能を提供しています。

上記のようにミキワメの性格検査の結果から16にタイプ分類をして、会社全体や部署ごとに性格特性の分布図を作成することが可能です。

チームビルディングなどに活用され好評を博している機能なのですが、ここにPDFダウンロード機能を追加しました。

課題


ミキワメ適性検査のフロントエンドはRailsのテンプレートエンジンであるhamlからReactへ移行中のため、その2つが混在している状況です。(2025/01 時点)

hamlからのPDF生成はすでに実装していましたが、Reactで作成された画面を直接PDF化した実績はありませんでした。

Reactで書かれたコンポーネントをPDF生成用にhamlで再作成し、従来通りバックエンドを介して実装することも検討しましたが工数がかかること、せっかくReactに移行したのにまたhaml書くんかいという気持ちがあり、もっと手軽にPDF生成する手段を模索することになりました。

技術選定


以下の条件で選定を進めました。

  • 導入コストが低い
  • 実装し易い
  • 主流ブラウザで動作可能
  • React Emotionが期待通り動作する

バックエンドを介さずPDF生成をしたいという需要は一定あると思うのですが、意外と選択肢が少なく、結論としてはhtml2canvasとjsPDFを組み合わせて採用することになりました。

html2canvasはHTML要素をキャプチャし画像化することが出来、jsPDFは画像からPDF生成が可能で、いずれもJavaScriptライブラリです。

react-pdfはJSXで直接レイアウトを記述できる点が魅力的でしたが、React v17までの対応に留まっており、ミキワメ適性検査が採用しているReact v18では動作しませんでした。また、Emotionの互換性もないため、今回の要件には適合しませんでした。

選定条件html2canvas + jsPDFreact-pdf
導入コストが低い✅ 導入が容易✅ 比較的容易
実装し易い✅ シンプルなAPI✅ JSXで直感的に記述可能
主流ブラウザで動作可能✅ Chrome, Firefox, Edge, Safari✅ Chrome, Firefox, Edge, Safari
React Emotionが動作✅ 影響なし❌ 互換性なし React v18に未対応
バックエンド不要でPDF生成✅ 可能✅ 可能
レイアウトの自由度⚠️ HTMLレンダリングに依存✅ JSXで柔軟に記述可能
PDFのカスタマイズ性✅ 縦横・サイズ指定可能✅ 詳細なスタイル調整可能

直面した課題


  • 一部のスタイルが反映されない
  • ブラウザ上でレンダリングが必要なため、表示の工夫が必要
  • 既存機能と一貫性のあるダウンロード操作を実装する必要がある

解決策


  • スタイルの問題は、デザイナーと相談し、許容範囲を判断
  • 一旦非表示エリアにPDF生成用コンポーネントを表示し、見えない形でPDFを生成
  • 既存のダウンロードフローと統一し、ユーザーのUXを維持

デモで使用した実装


シンプルですが以下でブラウザに表示させたPDF生成用コンポーネントをユーザが視認できないよう調整しました。

  1. ダウンロードボタン押下をトリガーにPDF生成用コンポーネントをブラウザに表示
  2. PDF生成、ダウンロード処理
  3. 完了後非表示にする

最終的な実装ではベースレイアウト外でPDF生成用コンポーネントを表示させ、視認のリスクを軽減させています。

export const PdfDownloadButton = ({ className }: Props) => {
const [showPDF, setShowPDF] = useState(false); // PDF生成用コンポーネントを表示する状態管理

// ボタンクリック時にPDF生成用コンポーネントを表示
const handleClick = () => {
setShowPDF(true);
};

// showPDF が true になったら PDF を生成し、完了後に元の状態に戻す
useEffect(() => {
if (showPDF) {
PdfDownload(); // PDF生成処理を実行
setShowPDF(false); // PDF生成が終わったら状態を元に戻す
}
}, [showPDF]);

return (
<div>
{/* PDFダウンロードボタン */}
<Button onClick={handleClick} className={className}>
PDFダウンロード
</Button>
{/* PDF生成用コンポーネント(表示されるとPDFを作成) */}
{showPDF && <PdfTargetComponent />}
</div>
);
};

// PDFを生成する関数
const PdfDownload = () => {
const target = document.getElementById('pdf-target'); // PDF対象の要素を取得
if (target === null) return;

// 取得した要素をPDFとして生成
generatePdf(target);
};

まとめ


フロントエンドのみでPDF生成を行う方法として、html2canvas + jsPDFは有力な選択肢のひとつです。特に、既存のバックエンドを改修せずにPDFを導入したい場合や、Reactで作成されたコンポーネントをそのままPDF化したい場合に有効です。一方で、スタイルの反映やブラウザレンダリングの工夫が必要になるため、導入前に許容範囲を整理することが重要です。

· 約5分
森田 有貴

森田です。

普段は何かしらの機能の実装の話をしてますが、今回は毛色の異なるお話をしようかと思います。

では早速こちらのコードをご覧ください。

import { useEffect, useState } from "react";

type Address = {
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
};

export const App = () => {
const [address, setAddress] = useState<Address>();
const [loading, setLoading] = useState(true);

useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060"); //fetchでデータを取得
const data = await response.json();

setAddress(data.results[0]); //data.results[0]をaddressにセット
setLoading(false); //fetchが終わったらloadingをfalseにする
};

fetchData();
}, []);

if (loading) {
return <div>loading...</div>;
} else {
return <div></div>;
}
};

やっていることとしてページをロードした際に指定した郵便番号からどこの住所のものなのかを取得しているだけです。

fetch中はloading...と表示し、終わったら取得した内容を表示するといったページです。

では問題です。addressの型は何でしょうか。

 

 

 

 

  〜シンキングタイム〜  

 

 

 

   

正解は...

Address | undifnedです!正解できましたかね?

そうなんです、useStateで初期値を設定していないのでundefinedがくっついてしまうんです。

なのでaddressの値を表示させようとすると{address?.address1}とオプショナルプロパティをつけて書かないといけなくなってしまうのです。これはよろしくないですね。

これをtypescriptの良さを活かして解決してみましょう!

解決方法としてはaddressを定義する際にその型を(Address & { loading: false }) | { loading: true }とし、その初期値を{ loading: true }とするというものです。

つまり

const [address, setAddress] = useState<(Address & { loading: false }) | { loading: true }>({ loading: true });

とするということです。

で、fetchしたデータをaddressに代入する際にloadingの値をfalseにします。

こうすることによってaddress値はundefinedにならなくなり、さらに別でloadingの値を設定する必要がなくなるのです。

文面だけだと何言ってるか少しよくわからないですね。

最初のコードを実際に編集してみましょう。

import { useEffect, useState } from "react";

type Address = {
address1: string;
address2: string;
address3: string;
kana1: string;
kana2: string;
kana3: string;
prefcode: string;
zipcode: string;
};

export const App = () => {
const [address, setAddress] = useState<(Address & { loading: false }) | { loading: true }>({ loading: true });

useEffect(() => {
const fetchData = async () => {
const response = await fetch("https://zipcloud.ibsnet.co.jp/api/search?zipcode=7830060"); //fetchでデータを取得
const data = await response.json();
setAddress({ ...data.results[0], loading: false }); //data.results[0]とloading:falseをセット
};

fetchData();
}, []);

if (address.loading) {
return <div>loading...</div>;
} else {
return <div>{address.address1}</div>;
}
};

このようにaddress.loading === falseになっている時だけaddressの値を表示するようにすると通常通り{address.address1}と表示できるようになるというわけです!

めちゃめちゃtypescriptの良さを活かしたtypescriptだからこそできる手法ではないでしょうか。

ちなみにこちらはチームのメンバーさんが見つけてくださったうひょさんの記事を参考にしています。 https://qiita.com/uhyo/items/d74af1d8c109af43849e

実際に社内のプロダクトでもこの手法を取り入れて開発をしています。

もしよかったら参考にしてみてください!

では。

· 約10分
森田 有貴

森田です。 先日 BlockNote について紹介しましたが、今回は BlockNote と yjs と Liveblocks を用いた共同編集機能の実装をご紹介します。

共同編集と聞くと難しそうに感じますが、そんなことは全くなくとても簡単に実装できます。

早速実装といきたいところですが、少しだけ yjs と Liveblocks について説明を...

yjs とは

yjs は CRDT(Conflict-free Replicated Data Type)という技術を持ちたフレームワークです。つまりコンフリクトしないデータの型を扱うことができます。

詳しくはこちら ↓

https://docs.yjs.dev/

Liveblocks とは

Liveblocks はリアルタイムでの共同編集やカーソル共有などのコラボレーション機能を簡単に追加できるツールキットです。

公式ドキュメントがとても充実しているので詳しくはこちらを参照ください ↓

https://liveblocks.io/docs

準備

Nextjs を作成。今回は App Router + Typescript でいきます。そのほかは好みで設定してもらえればいいと思います。

$ npx create-next-app collaborative-editor
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

Liveblocks 周りの設定

Liveblocks 関連のライブラリをインストールします

npm i @liveblocks/client @liveblocks/react @liveblocks/node @liveblocks/yjs

liveblocks を初期化

npx create-liveblocks-app@latest --init --framework react

何やら質問されるので、y で実行しましょう。

$ npx create-liveblocks-app@latest --init --framework react
Need to install the following packages:
create-liveblocks-app@2.20240816.0
Ok to proceed? (y) y

▀█████▀ ▄
▀██▀ ▄██▄
▀ ▄█████▄

Liveblocks


✨ liveblocks.config.ts has been generated!

これでアプリ側の設定は完了。

続いて API キーを取得しにいきましょう。

まずこちらからサインアップhttps://liveblocks.io/

API keys を選択

image1

Generate key...をクリックして

image2

キーを生成!

image3

Public key と Secret key は後々使うのでメモしておきましょう。

BlockNote の設定

先日の記事と同じように blocknote のライブラリもインポートしておきましょう

npm i @blocknote/core @blocknote/react @blocknote/mantine

実装

ではいよいよ実装に入っていきましょう。

まず先程取得した API キーを環境変数に設定しておきましょう。

/.env.local
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= LIVEBLOCKS_SECRET_KEY=

続いてバックエンドを作成します。

/src/app/api/liveblocks-auth/route.ts
import { Liveblocks } from "@liveblocks/node";
import { NextResponse } from "next/server";
import { nanoid } from "nanoid";

const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});

export async function POST() {
const liveSession = liveblocks.prepareSession(nanoid());

liveSession.allow("*", liveSession.FULL_ACCESS);

const { body, status } = await liveSession.authorize();
return new NextResponse(body, { status });
}

続いてLiveblocks のライブラリを用いてルームの作成接続などの機能のコンポーネントを作成します。

/src/app/Components/Room.tsx
"use client";

import { LiveblocksProvider, RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";
import { ReactNode } from "react";

type Props = {
children: ReactNode;
};

export const Room = ({ children }: Props) => {
return (
<LiveblocksProvider authEndpoint={"/api/liveblocks-auth"}>
<RoomProvider id="test-room">
<ClientSideSuspense fallback={<div>...loading</div>}>{children}</ClientSideSuspense>
</RoomProvider>
</LiveblocksProvider>
);
};

authEndpointは先程作成したバックエンドのエンドポイントを設定しましょう。

次にBlockNoteにyjsを組み込んだエディタを作成します。

/src/app/Components/Editor.tsx
"use client";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@liveblocks/react/suspense";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

type EditorProps = {
doc: Y.Doc;
provider: any;
};

export function Editor() {
const room = useRoom();
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();

useEffect(() => {
const yDoc = new Y.Doc();
const yProvider = new LiveblocksYjsProvider(room, yDoc);
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, [room]);

if (!doc || !provider) {
return null;
}

return <BlockNote doc={doc} provider={provider} />;
}

function BlockNote({ doc, provider }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: "User",
color: "#ff0000",
},
},
});

return <BlockNoteView editor={editor} />;
}

作成したコンポーネントをページに組み込んで...

/src/app/collaborative-editing/page.tsx
import { Room } from "../Components/Room";
import { Editor } from "../Components/Editor";

const Page = () => {
return (
<Room>
<Editor />
</Room>
);
};

export default Page;

はい完成🎉

video1

ちょっと簡単すぎじゃないですかね。

おまけ

ルームの接続設定について

RoomコンポーネントのRoomProviderのidでルーム選択ができるようになっています。

/src/app/Components/Room.tsx:13

<RoomProvider id="test-room">

今回はtest-roomになっていますね。

また Liveblocks のダッシュボードから作成されたルームを管理することもできます。

image4

test-roomが作成されていますね。

またルームの id に制約をかけることもできます。

/src/app/api/liveblocks-auth/route.ts:12
liveSession.allow("room:*", liveSession.FULL_ACCESS);

このようにroom:*とするとルーム id にroom:がついていないと接続できないようになります。

今ルーム id をtest-roomにしているのでそのままでは接続できないようになっているはずです。

room:test-roomにすると再度接続できるようになるということです。

接続中の表示

ClientSideSuspensefallbackで接続中に表示されるないようを変更することができます。以下のように変更してみましょう。

/src/app/Components/Room.tsx:14
<ClientSideSuspense fallback={<div>...読み込み中</div>}>{children}</ClientSideSuspense>

video2.gif

任意のコンポーネントで指定できるので、ローディングでよくあるクルクルするやつなどを表示するのもいいかもしれないですね。

ユーザー名と色の変更

相手に表示される自分のユーザー名と色を変更できるようにしてみましょう。

以下でを設定しています。

/src/app/Components/Editor.tsx:46
user: {
name: "User",
color: "#ff0000",
},

レンダリング時に設定されてないといけないので、それも踏まえてEditorコンポーネントを編集してみましょう。

/src/app/Components/Editor.tsx
"use client";
import { useEffect, useState } from "react";
import { BlockNoteEditor } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import * as Y from "yjs";
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import { useRoom } from "@liveblocks/react/suspense";
import "@blocknote/core/fonts/inter.css";
import "@blocknote/mantine/style.css";

type EditorProps = {
doc: Y.Doc;
provider: any;
name: string;
color: string;
};

export function Editor() {
const room = useRoom();
const [doc, setDoc] = useState<Y.Doc>();
const [provider, setProvider] = useState<any>();
const [name, setName] = useState("");
const [color, setColor] = useState("");
const [showEditor, setShwoEditor] = useState(false);

useEffect(() => {
const yDoc = new Y.Doc();
const yProvider = new LiveblocksYjsProvider(room, yDoc);
setDoc(yDoc);
setProvider(yProvider);

return () => {
yDoc?.destroy();
yProvider?.destroy();
};
}, [room]);

if (!doc || !provider) {
return null;
}

if (showEditor) {
return (
<BlockNote doc={doc} provider={provider} name={name} color={color} />
);
} else {
return (
<div style={{ display: "flex", justifyItems: "center" }}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="名前"
/>
<input
value={color}
onChange={(e) => setColor(e.target.value)}
type="color"
/>
<button onClick={() => setShwoEditor((prev) => !prev)}>接続</button>
</div>
);
}
}

function BlockNote({ doc, provider, name, color }: EditorProps) {
const editor: BlockNoteEditor = useCreateBlockNote({
collaboration: {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: name,
color: color,
},
},
});

return <BlockNoteView editor={editor} />;
}

名前と色を入力したらテキストを変種できるようにしたという感じですね。

video3.gif

というわけで実装できました。

おわり

今回は共同編集の実装を紹介しました。 Liveblocksはテキストの編集以外にも相手のカーソル表示やリアルタイムの通知機能など様々な機能が用意されています。 yjsについてもMap型やArray型などあり、使い方によって様々に活用できると思うので、ぜひいろいろ試していただければと思います。

今回テキストエディタはBlockNoteを使用しましたが、他のエディタでもほとんど同様に実装できるようになっています。この辺りの話はLiveblocksのドキュメントを参照してみてください。

ではまた。

· 約9分
森田 有貴

森田です。

現在開発しているプロダクトで BlockNote というリッチテキストエディタを使う機会があったのですが、これがめちゃめちゃ便利だったので今回はその紹介をしたいと思います!

BlockNote とは?

BlockNote は React で Notion のようなブロックベースのテキストエディタを実装できるライブラリです。デフォルトの状態で Notion に引けを取らない UX が設定されており、とても簡単に高クオリティなテキストエディタを実装できます。

https://www.blocknotejs.org/

実装

長々と説明するより実際に見てもらうほうが早いと思うので、早速で実装してみましょう! 今回もNextjsで行きます

サクッとプロジェクトを作成して...

$ npx create-next-app blocknote-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

必要なライブラリをインポート

npm install @blocknote/core @blocknote/react @blocknote/mantine

エディタ用のコンポーネントを作成して...

/src/app/page.tsx
"use client";

import "@blocknote/core/fonts/inter.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";

export const Editor = () => {
const editor = useCreateBlockNote();
return (
<div>
<BlockNoteView editor={editor} />
</div>
);
};

ページに表示するだけ!

/src/app/page.tsx
import { Editor } from "./components/Editor";

const Page = () => {
return (
<>
<Editor />
</>
);
};

export default Page;

これだけで実装完了!

video

とっても簡単にNotionのようなテキストエディタを実装できてしまいました🎉🎉🎉

できること

blocknoteでは文字を入力できるのはもちろん、文字のサイズや色の変更、画像や動画などさまざまなコンテンツを入力表示させることができます。

image1

image2

入力欄の左の+をクリックすると入力できるものリストが表示されます。

基本的にはMarkdown記法と同様に#でHeadingにしたり、 - でリスト表示にしたりという感じで入力できます。また、+の横の点々の部分をクリックすると文字の色や背景色なども選ぶことができます。

画像や動画などはurlを入力することによって表示させることができます。

さらにBlockNoteという名前の通り、入力したコンテンツをブロックとして移動させることも簡単にできてしまいます。

video2

ものすごい機能が充実していますね😲

スタイル変更

スタイルもかなり自由に変更できるようになっています。 いくつか抜粋してご紹介します。

文字サイズ変更

初期設定では文字のサイズが大きすぎるので変更してみましょう。

.editor-custom-style .bn-editor h1 {
font-size: 18px;
}

.editor-custom-style .bn-editor h2 {
font-size: 16px;
}

.editor-custom-style .bn-editor h3 {
font-size: 14px;
}

.editor-custom-style .bn-editor {
font-size: 14px;
}

cssインポートしてクラスを指定すると...

import "./editor.css";

export const Editor = () => {
const editor = useCreateBlockNote();

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" />
</div>
);
};

image3

いい感じのサイズに変更できました!

文字サイズ以外にも色々カスタマイズができるようになっています。

テーマ

続いてテーマを編集してましょう! 背景色を赤色にしてみます。

import { BlockNoteView, darkDefaultTheme } from "@blocknote/mantine";
import { Theme } from "@blocknote/mantine";

export const Editor = () => {
const editor = useCreateBlockNote();

const lightRedTheme = {
colors: {
editor: {
background: "red",
},
},
} satisfies Theme;

const redTheme = {
light: lightRedTheme,
dark: darkDefaultTheme,
};

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" theme={redTheme} />
</div>
);
};

image4

むちゃくちゃ目に悪そうですね。

みてわかる通りライトテーマとダークテーマそれぞれ設定できるようになっています。 今回はライトテーマのみにカスタムテーマを当てています。

こちらもかなり自由度高くカスタマイズできるようになっています。

詳しくはこちら↓

https://www.blocknotejs.org/docs/styling-theming/themes

言語変更

デフォルトではプレイスホルダーやメニューの部分の言語が英語になっています。 日本語の設定が用意されているので変更してみましょう。

import { locales } from "@blocknote/core";

export const Editor = () => {
const editor = useCreateBlockNote({
dictionary: locales.ja,
});

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" />
</div>
);
};

localesをインポートしてlocales.jaを設定するだけ。

image5

特に変な日本語になっている部分もないですね。

おまけ

入力した内容を取得する

textareaタグのonChange={(e) => console.log(e.target.value)}的なことをしてみましょう!

Reactのテキストエディタですからね。内容が取得できてなんぼですからね。

入力された内容はeditor.documentで取得ができます。また、inputやtextareaと同様にonChangeで入力内容の変化を感知できます。

export const Editor = () => {
const editor = useCreateBlockNote();

const onChange = () => {
console.log(editor.document);
};

return (
<div>
<BlockNoteView editor={editor} onChange={onChange} />
</div>
);
};

video3

というわけで入力が取得できました。

ただ、みてわかる通りこの実装方法ではBlockNote特有のBlock型で出力されてしまいます。

なのでマークダウンに変換して出力してみましょう!

blocksToMarkdownLossyを使ってonChangeを以下のように変更します

const onChange = async () => {
const markdown = await editor.blocksToMarkdownLossy(editor.document);
console.log(markdown);
};

video4

マークダウンで出力されるようになりました!

プログラム側から入力する

続いてプログラム側から内容を入力する方法をご紹介します。

こちらはinputやtextareaとは全く異なります。

export const Editor = () => {
const editor = useCreateBlockNote();

const markdown = `# Hello, world!`;

const loadMarkdown = async () => {
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
editor.replaceBlocks(editor.document, blocks);
};

return (
<div>
<BlockNoteView editor={editor} />
<button onClick={() => loadMarkdown()}>読み込む</button>
</div>
);
};

loadMarkdown関数で入力するマークダウンの文字列をBlock型に変換し、editor.replaceBlocksで置き換えているという感じです。 

実際に動かしてみるとこんな感じ。

video5

replace以外にinsertなどさまざまな操作が可能になっています。

詳しくはこちら↓

https://www.blocknotejs.org/docs/editor-api/manipulating-blocks

おわり

今回はBlockNoteをご紹介しました。今回紹介できたのはほんの一部で他にも便利な機能が沢山用意されているので、ぜひ実際に使っていただければと思います!

公式のDiscordサーバーも活発に動いており、日々質問や不具合報告など飛び交っています。わからないことがあればこちらで聞くといいかもしれないですね。

機会があれば実際にBlockNoteを用いた機能開発も紹介したいと思っています!

ではまた。

· 約6分
山田 哲也

みなさん、こんにちは! 山田です。

前回の記事で Figma を使ってボタンコンポーネントを作ってみました。
今回はそのデザインを元に React と Tailwind CSS で実装していきたいと思います。

開発環境の準備

今回開発環境に Nextjs を選びました。
Next.js はセットアップ時に Tailwind CSS のインストールを選択できるので、すぐに開発を始められます。

セットアップ時の質問は全てデフォルトで進めていきます。

> npx create-next-app@latest
Need to install the following packages:
create-next-app@14.0.4
Ok to proceed? (y) y
✔ What is your project named? … button-ui-example
✔ Would you like to use TypeScript? … No / Yes # Yesを選択
✔ Would you like to use ESLint? … No / Yes # Yesを選択
✔ Would you like to use Tailwind CSS? … No / Yes # Yesを選択
✔ Would you like to use `src/` directory? … No / Yes # Noを選択
✔ Would you like to use App Router? (recommended) … No / Yes # Yesを選択
✔ Would you like to customize the default import alias (@/*)? … No / Yes # Noを選択

Next.js のセットアップが完了したら、プロジェクトのディレクトリに移動しておきます。

UI の確認は Storybook で行うので、こちらもセットアップします。

npx storybook@latest init

セットアップが完了すると、自動的に Storybook が起動します。

Storybook

プリセットでいくつかコンポーネントが用意されていますが、不要なので削除しておきます。

rm -rf stories/*

Storybook の設定

次に Storybook の設定を行います。

簡単なボタンコンポーネントのファイルを作成し、以下の内容で保存します。

// app/components/Button.tsx
import { ReactNode, FC } from "react";

type Props = {
children: ReactNode;
};

export const Button: FC<Props> = ({ children }) => {
return <button>{children}</button>;
};

次に、このコンポーネントを Storybook で確認できるようにします。

// stories/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "../app/components/Button";

const meta = {
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
args: {
children: "ボタン",
},
};

Storybook を確認すると、ボタンコンポーネントが表示されます。

meta オブジェクトの tags に autodocs を指定することで、コンポーネントのドキュメントを自動生成することができます。
また、parameters に layout: "centered"を指定することで、コンポーネントを中央に表示することができるので覚えておくと便利です。

Storybook

最後に preview.js に globals.css を読み込ませます。
この時 globals.css に記載されている@tailwind xxxx以外の内容は不要なので削除しておきます。

// .storybook/preview.js
import type { Preview } from "@storybook/react";
import "../app/globals.css";

(省略)

これで Tailwind CSS によるスタイルが Storybook に反映されるようになりました。

ボタンコンポーネントの実装

Figma でデザインしたボタンコンポーネントにはいくつかのバリアントがありました。
ボタンコンポーネントに渡すプロパティの値によってバリアントを変化させるのが一般的かと思いますが、プロパティの数が多くなってくると、条件分岐を独自に実装するのはかなり面倒です。

そこで、「Class Variance Authority」というライブラリを使って、複数のバリアントを持つコンポーネントを簡単に実装できるようにします。

まずはライブラリをインストールします。

npm install class-variance-authority

次に、Button コンポーネントを以下のように修正します。

// app/components/Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { ButtonHTMLAttributes, FC } from "react";

const button = cva("text-white rounded-md", {
variants: {
intent: {
primary: "bg-blue-500 hover:bg-blue-600",
secondary: "bg-pink-500 hover:bg-pink-600",
},
size: {
sm: "px-2 py-1 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
});

export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof button> {}

export const Button: FC<ButtonProps> = ({
className,
intent,
size,
children,
...props
}) => {
return (
<button className={button({ intent, size, className })} {...props}>
{children}
</button>
);
};

cva メソッド利用してバリアントの型を構築していきます。
第一引数に共通で適用されるクラス、第二引数にバリアントとそのバリアントに適用されるクラスを指定することができます。
また、defaultVariants を追加することで、デフォルトで表示されるバリアントを指定することができます。

(なんて便利なんだ...)

では、最後に Storybook でコンポーネントを確認してみましょう。

Storybook

うまく切り替えができているようですね 🎉

以上でボタンコンポーネントの実装は完了です。
複数のバリアントを持つコンポーネントを実装する際は、ぜひこのライブラリを使ってみてください。

それではまた次回!