shwldshwld5 days ago

tiptap のメンションを markdown で永続化するためのショートコード経由ラウンドトリップ

リッチテキストエディタ(tiptap)でメンション(@ユーザー)を扱うとき、保存先が markdown だと素直に往復できない。markdown には mention ノードに相当する記法がないので、エディタ → 保存 → 再表示で id とラベルが落ちる。

専用ショートコード [@ id="..." label="..."] をストレージ上のキャリアに使い、エディタ↔markdown 変換で詰め替える方式が安定した。

const MENTION_LINK = /\[@([^\]\n]+)\]\(mention:([^)]+)\)/g;
const MENTION_SHORTCODE = /\[@\s+([^\]]*)\]/g;

export function toEditorShortcodes(md: string) {
  return md.replace(MENTION_LINK, (_, label, id) =>
    `[@ id="${escape(id)}" label="${escape(label)}"]`
  );
}

export function fromEditorShortcodes(md: string) {
  return md.replace(MENTION_SHORTCODE, (full, attrs) => {
    const { id, label } = parseAttrs(attrs);
    return id ? `[@${escapeMd(label ?? id)}](mention:${id})` : full;
  });
}

tiptap 側は Mention 拡張の renderText@${label} を出すだけにし、独自の Suggestion ポップアップは無効化(suggestion.allow: () => false)して既存の候補UIをそのまま使う:

import { Mention } from "@tiptap/extension-mention";

export const AppMention = Mention.configure({
  renderText: ({ node }) => `@${node.attrs.label ?? node.attrs.id}`,
  suggestion: { char: "@", allow: () => false },
});

ポイント:

  • markdown を一次ストレージにするなら、ノード固有の属性は「人間に読める識別可能なショートコード」で運ぶのが扱いやすい
  • 表示・検索ヒット・他クライアントの解釈を考えて、id とラベルの両方を残しておく(id 単独だと表示崩れ、label 単独だと参照ができない)
  • tiptap の Suggestion を切るかどうかは、候補UIを別実装で持っているかで決める