Upgrade to Pro — share decks privately, control downloads, hide ads and more …

フロントエンドだけで戦わない、既存 wiki サービスへの WYSIWYG エディタ導入記 /...

フロントエンドだけで戦わない、既存 wiki サービスへの WYSIWYG エディタ導入記 / (Almost) WYSIWYG Editor: Reflections from a Real-World Case

「記法を覚えるのが大変」「スマホで書きづらい」といった記法ベースのエディタの課題を、WYSIWYG エディタの導入で解消する取り組みの紹介です。
Tiptap での実装で直面した、「みたまま」ならではの難しさを、具体的な事例とともにお話しします。
フロントエンドだけで戦わずバックエンドとも協調する、「ほぼ(Almost)みたまま」という選択肢を紹介します。

関連イベント: https://web-study.connpass.com/event/391357/

Avatar for ahu

ahu

May 26, 2026

More Decks by ahu

Other Decks in Programming

Transcript

  1. 2 自己紹介 • 2021年4月 新卒入社 • フロントエンドエンジニア • pixiv本体の、マンガビューア・ホーム画面・ 小説文字数チェッカーの実装などを担当

    • 2024年6月から百科事典チームに参加 • 技術面のリーダーとして、機能開発や基盤整備 の計画・進行・実装・レビューなどを担当 ahu エンジニア
  2. 5 • 「みんなでつくる百科事典」が合言 葉の、オンライン百科事典サービス • 17年目だが、フロントエンドは Next.js・Tailwind・Turbopack • いわゆる wiki

    サービスで、 独自の記法で記事を装飾できる e.g. * 見出し [[リンク]] [x:埋め込み] ピクシブ百科事典 というサービス https://dic.pixiv.net/
  3. 7 • 記法を覚えてもらうコストが高い ◦ なんか難しそう、な印象 ◦ 試しながらが難しい • ちょっと直したいだけなのに、 スマホで記法を打つのが大変

    ◦ スマホ:PC の閲覧比は約 4:1 ◦ 「面倒だしまあいいか〜」を 「できた!」に繋げたい 記法エディタの 何がイマイチ? https://dic.pixiv.net/ae/テスト記事
  4. 8 • 記法を覚えてもらうコストが高い ◦ 「みたまま」の敷居の低さ ◦ 試しながらが簡単で、機能の 学習曲線が緩やかになる • スマホで記法を打つのが大変

    ◦ 選択やタップだけで完結する とにかく初心者に優しい!入りやすい! WYSIWYG エディタ ならこうなれる!
  5. 9 • 記法を覚えてもらうコストが高い ◦ 「みたまま」の敷居の低さ ◦ 試しながらが簡単で、機能の 学習曲線が緩やかになる • スマホで記法を打つのが大変

    ◦ 選択やタップだけで完結する とにかく初心者に優しい!入りやすい! でもちゃんと「みたまま」を作れるか? WYSIWYG エディタ ならこうなれる!
  6. そのまえに少し前提 • ProseMirror をベースにした、Tiptap というライブラリを使っている ◦ 文書構造を JSON として持つのが特徴(HTML の木構造とは少し違う形)

    ◦ ノードやマークのビューに、React コンポーネントが使える! • Tiptap 公式が提供しているノードは使わず、ほとんどのノードを自作している ◦ 表とバブルメニュー以外は、全て自作のノード・マーク ◦ X のポスト・画像の横並び表示・pixiv 作品の埋め込みなど、HTML に 対応するマークアップがないノードは、結局自作しないといけない ◦ 既に記事の表示があるので、その React コンポーネントを使い回したい • 記法エディタは残しつつ、新しく WYSIWYG エディタも追加しようという話 12
  7. 事例1: コピペで入力の制限が超えられる • リストのインデントを3段までに制限したいとする ◦ 2段目 ▪ 3段目 • (これ以上はインデントできない)

    • インデントの増減は、UI から or Tab 入力でできる ◦ UI に制約をかける → できる(disabled にする) ◦ Tab 入力を無効にする → できる(追加をしない) ◦ しかし、別の2段のリストを3段目にコピペすると…! 13
  8. 事例1: コピペで入力の制限が超えられる • エディタでは、「範囲選択 → コピペ or delete」のような入力もありうる • 操作を組み合わせることで、本来は避けたい文書構造も作れてしまう

    ◦ li は、1つの ul の中に可能な限り連続する(ul > [li, li, …])のが普通だが、 [ul, 段落, ul] を作って間の段落を消しても、2つの ul の li はマージされない 14
  9. 事例1: コピペで入力の制限が超えられる • エディタでは、「範囲選択 → コピペ or delete」のような入力もありうる • 操作を組み合わせることで、本来は避けたい文書構造も作れてしまう

    ◦ li は、1つの ul の中に可能な限り連続する(ul > [li, li, …])のが普通だが、 [ul, 段落, ul] を作って間の段落を消しても、2つの ul の li はマージされない • 更新を監視 → 文書構造全体の整合性を確認する必要がある ◦ 更新内容だけでなく、更新後に周辺の親子・兄弟関係がどうなるかが重要 • これは「みたまま」だから起きる問題 ◦ 記法エディタなら、ビューに変換するタイミングで表示を工夫すればいい ◦ 「みたまま」だと、入力のたびに整合性の確認と修正が必要 15
  10. 事例2: カーソルが意図しない位置に出る • Tiptap や ProseMirror のカーソル移動は、実は視覚的な表示に依存している ◦ e.g. 折り返されて2行になった段落の2行目で

    ↑ キーを押すと、前の段落に 移動するのではなく1行目の近い位置に移動する ◦ editor.view.endOfTextblock のような、境界判定を行うための関数がある • このため、ノードの定義やビュー(React のコンポーネント)の実装次第では、 カーソルが意図しない位置に置かれてしまうことがある ◦ この状態で文字を入力すると、表示が壊れたり、ノードの分割(split)が 試みられて、壊れたノードが複製されたりする 16
  11. 事例2: カーソルが意図しない位置に出る • ノードやビューの要件に合わせて、複数のアプローチが必要 ◦ selectable: false で、ノードを選択不能にする ◦ min-width:

    1em で、空の入力欄の領域を確保する ◦ onSelectionUpdate で、カーソルを子ノードに飛ばす ◦ addKeyboardShortcuts で、ArrowUp や Enter キーの動作を上書きする 17
  12. 事例2: カーソルが意図しない位置に出る • ノードやビューの要件に合わせて、複数のアプローチが必要 ◦ selectable: false で、ノードを選択不能にする ◦ min-width:

    1em で、空の入力欄の領域を確保する ◦ onSelectionUpdate で、カーソルを子ノードに飛ばす ◦ addKeyboardShortcuts で、ArrowUp や Enter キーの動作を上書きする • これも「みたまま」だから起きる問題 ◦ 記法エディタでは、入力時のカーソルの位置はビューとは独立している ◦ 「みたまま」では、カーソルの位置のズレが、そのまま表示を破壊する 18
  13. 事例3: Undo できないコマンドが生まれる • Tiptap や ProseMirror では、「コマンド」という低レベルな API を組み合わせて

    エディタの状態を更新する(組み合わせを新たなコマンドとしても定義できる) • ノードの置換を 削除 → 追加 で実装したら、2回 Undo が必要なコマンドになった ◦ transaction(DB のそれと同じく変更をまとめた単位)が2つできたのが原因 19
  14. 事例3: Undo できないコマンドが生まれる • Tiptap や ProseMirror では、「コマンド」という低レベルな API を組み合わせて

    エディタの状態を更新する(組み合わせを新たなコマンドとしても定義できる) • ノードの置換を 削除 → 追加 で実装したら、2回 Undo が必要なコマンドになった ◦ transaction(DB のそれと同じく変更をまとめた単位)が2つできたのが原因 ◦ 同じ transaction を共有する設計にするか、置換を別で実装する必要がある ◦ コマンドの実装では、アトミック性を強く意識する必要がある • これも「みたまま」だから起きる問題 ◦ 記法エディタなら、入力履歴がそのまま Undo の単位になる ◦ 「みたまま」では、ユーザーの操作と transaction を一致させる必要がある 20
  15. 事例4: ビューとのやり取りの設計が難しい • Tiptap では、ノードのビューに React のコンポーネントが使える ◦ React(アプリケーション)> Tiptap

    > React(ノードのビュー)の階層構造 • ビューからアプリケーションにデータを送る場合 ◦ Context API で、アクション(モーダルを開く等)の引数としてデータを渡す • アプリケーションからビューにデータを送る場合 ◦ コマンドを定義して、コマンドの引数としてデータを渡す(ノードの更新) ◦ ノードの属性の更新は、Tiptap > React の React に props 経由で届く 21
  16. 事例4: ビューとのやり取りの設計が難しい • Tiptap では、ノードのビューに React のコンポーネントが使える ◦ React(アプリケーション)> Tiptap

    > React(ノードのビュー)の階層構造 • ビューからアプリケーションにデータを送る場合 ◦ Context API で、アクション(モーダルを開く等)の引数としてデータを渡す • アプリケーションからビューにデータを送る場合 ◦ コマンドを定義して、コマンドの引数としてデータを渡す(ノードの更新) ◦ ノードの属性の更新は、Tiptap > React の React に props 経由で届く • どちらもアクションとペイロードを渡す書き方になり、設計や方針が必要になる ◦ データの流し方の方針を決めず場当たり的に書くと、カオスになりやすい 22
  17. /* ビューからアプリケーションに データを送る場合 */ // React(アプリケーション): Context にアクション(モーダルの open)とペイロードを定義する type

    EditModalState<State, Callbacks = { onDelete: () => void }> = { data: State | null; /* ペイロードその1: 編集モーダルの入力欄に表示する初期データなど */ callbacks: Callbacks; /* ペイロードその2: 編集モーダル中の「削除」ボタンで呼ぶ処理など */ open: (data: State, callbacks?: Callbacks) => void; close: () => void; }; type EditModalStates = { illustBlock: EditModalState<IllustBlock> }; export const EditModalsContext = createContext<EditModalStates | null>(null); export const useOpenEditModal = () => { const ctx = useContext(EditModalsContext); if (!ctx) throw new Error("Missing provider"); return { illustBlock: ctx.illustBlock.open }; }; // React(ノードのビュー): データをアクションの引数として渡す export const IllustsComponent = ({ children, illustIds, pos }: Props) => { const { illustBlock } = useOpenEditModal(); const onClickAdd = () => illustBlock({ index: illustIds.length, pos: pos() }); return (/* <div>...</div> */); }; 23
  18. /* アプリケーションからビューに データを送る場合 */ // Tiptap: ノードに属性とコマンドを定義する export const Illusts

    = Node.create({ addAttributes() { return { illustIds: { default: [] as string[] } }; }, addCommands() { return { /** attrs.illustIds[index] = illustId; 相当の処理 */ setIllustIds: (index, illustId) => ({ state, tr }) => { /* */ }, }; }, }); // React(アプリケーション): データをコマンドの引数として渡す export const IllustBlockModal = ({ illustData, onDelete, onClose }: Props) => { const handleEditConfirm = (data: IllustBlock) => { editor.commands.setIllustIds(illustData.index, data.illustId); onClose(); }; return (/* <Modal>...</Modal> */); }; 24
  19. /* アプリケーションからビューに データを送る場合 */ // Tiptap: setIllustIds の呼び出しで illustIds 属性が更新される

    export const Illusts = Node.create({ addAttributes() { return { illustIds: { default: [] as string[] } }; }, addCommands() { return { /** attrs.illustIds[index] = illustId; 相当の処理 */ setIllustIds: (index, illustId) => ({ state, tr }) => { /* */ }, }; }, }); export const IllustBlockModal = ({ illustData, onDelete, onClose }: Props) => { const handleEditConfirm = (data: IllustBlock) => { editor.commands.setIllustIds(illustData.index, data.illustId); onClose(); }; return (/* <Modal>...</Modal> */); }; 25
  20. /* アプリケーションからビューに データを送る場合 */ type EditModalState<State, Callbacks = { onDelete:

    () => void }> = { data: State | null; callbacks: Callbacks; open: (data: State, callbacks?: Callbacks) => void; close: () => void; }; type EditModalStates = { illustBlock: EditModalState<IllustBlock> }; export const EditModalsContext = createContext<EditModalStates | null>(null); export const useOpenEditModal = () => { const ctx = useContext(EditModalsContext); if (!ctx) throw new Error("Missing provider"); return { illustBlock: ctx.illustBlock.open }; }; // React(ノードのビュー): ノードから illustIds 属性を props として受け取る export const IllustsComponent = ({ children, illustIds, pos }: Props) => { const { illustBlock } = useOpenEditModal(); const onClickAdd = () => illustBlock({ index: illustIds.length, pos: pos() }); return (/* <div>...</div> */); }; 26
  21. /* ビューからアプリケーションに データを送る場合(再掲) */ type EditModalState<State, Callbacks = { onDelete:

    () => void }> = { data: State | null; callbacks: Callbacks; open: (data: State, callbacks?: Callbacks) => void; close: () => void; }; type EditModalStates = { illustBlock: EditModalState<IllustBlock> }; export const EditModalsContext = createContext<EditModalStates | null>(null); export const useOpenEditModal = () => { const ctx = useContext(EditModalsContext); if (!ctx) throw new Error("Missing provider"); return { illustBlock: ctx.illustBlock.open }; }; // React(ノードのビュー): illustBlock の呼び出しでモーダルが開く export const IllustsComponent = ({ children, illustIds, pos }: Props) => { const { illustBlock } = useOpenEditModal(); const onClickAdd = () => illustBlock({ index: illustIds.length, pos: pos() }); return (/* <div>...</div> */); }; 27
  22. /* ビューからアプリケーションに データを送る場合(再掲) */ export const Illusts = Node.create({ addAttributes()

    { return { illustIds: { default: [] as string[] } }; }, addCommands() { return { /** attrs.illustIds[index] = illustId; 相当の処理 */ setIllustIds: (index, illustId) => ({ state, tr }) => { /* */ }, }; }, }); // React(アプリケーション): illustBlock からペイロードを props として受け取る export const IllustBlockModal = ({ illustData, onDelete, onClose }: Props) => { const handleEditConfirm = (data: IllustBlock) => { editor.commands.setIllustIds(illustData.index, data.illustId); onClose(); }; return (/* <Modal>...</Modal> */); }; 28
  23. 改めて、なぜ WYSIWYG エディタを? • ユーザーは、難しそうな印象がある記法を意識せずに、編集を始められる • ユーザーは、こまめにプレビュー画面に移動しなくても、すぐ表示を確認できる ◦ 結果、ユーザーが機能を覚える上での学習曲線が緩やかになる •

    ユーザーは、スマホでも入力が簡単になり、「ちょっと直したい」が楽にできる • → WYSIWYG エディタの導入は、ユーザー体験の向上に絶大な効果がある • 人や時間のリソースが潤沢なら、「みたまま」の完成度を限界まで高めるのが理想 • しかし挙動の複雑さから、そのコストもかなり高い(という事例を紹介してきた) ◦ 単純に実装量も多く、フロントエンドとバックエンドの工数比率も偏りがち 32
  24. What You See Is (Almost) What You Get • 前提として、バックエンドでも入力をバリデーションする必要はある

    ◦ 不正な文書構造が送られていないか・危険なリンクを含んでいないか・… • UI など体験に直結する部分はフロントで頑張るしかないが、そうでない部分を バックエンドに持ってもらうことはできるかも?(e.g. 文書構造の正規化) 33
  25. What You See Is (Almost) What You Get • 前提として、バックエンドでも入力をバリデーションする必要はある

    ◦ 不正な文書構造が送られていないか・危険なリンクを含んでいないか・… • UI など体験に直結する部分はフロントで頑張るしかないが、そうでない部分を バックエンドに持ってもらうことはできるかも?(e.g. 文書構造の正規化) • (今回のようにアウトカムが特に大きい場合)その機能が存在することや、 それを早くデリバリーすることもまた、ユーザー体験への大きな寄与ではないか? • フロントエンドだけで戦わない、「ほぼ(Almost)みたまま」という選択肢 ◦ 「もう Markdown でよくないか?」に対する1つの個人的なアンサー 34
  26. 例えばピクシブ百科事典なら? • 前提として、ピクシブ百科事典には記法エディタが既にある ◦ 記法の表示を確認するための、プレビュー画面も既にある • 「事例1: コピペで入力の制限が超えられる」で問題になった深いリストは、 記法エディタでも書くこと自体はできるもの(書けること ≠

    表示すること) ◦ 危険な URL も同様で、バックエンドで無視・正規化する仕組みが既にある • エディタの画面はあくまでエディタで、実際の表示とは真には一致しない ◦ エディタの画面では、編集操作の UI や入力欄のための空の領域が必要 35
  27. 例えばピクシブ百科事典なら? • 前提として、ピクシブ百科事典には記法エディタが既にある ◦ 記法の表示を確認するための、プレビュー画面も既にある • 「事例1: コピペで入力の制限が超えられる」で問題になった深いリストは、 記法エディタでも書くこと自体はできるもの(書けること ≠

    表示すること) ◦ 危険な URL も同様で、バックエンドで無視・正規化する仕組みが既にある • エディタの画面はあくまでエディタで、実際の表示とは真には一致しない ◦ エディタの画面では、編集操作の UI や入力欄のための空の領域が必要 • データの正規化はバックエンドに任せ、完全な表示はプレビューで担保することで フロントエンドの実装量を減らして、リリースを早めるプランが取れる 36