ホストアプリが直接制御できる組合せ型マークダウンエディタパッケージ
chaeditorはchaenプロジェクト内のマークダウンエディタを独立したパッケージとして取り出す作業から始まりました。
最も注意を払った点は編集機能をパッケージとして取り出しつつ、ホストアプリの制御権をどれだけ残せるかでした。
既存のマークダウンエディタはどちらか一方に偏ることが多かったです。直接組み合わせる必要があるほどスカスカだったり、逆に機能は多いがUIと動作方式が強く固定されてサービスに自然に溶け込ませにくかったです。実際にはホストごとに求められる機能も異なり、リンクのプレビュー・ファイルアップロード・イメージレンダリングのように既存のAPIと直接つながらなくてはならない部分も異なります。
chaeditorはvelog・ネイバーブログ・ディスコード・Notionで有用だと感じたライティング体験を一つの基本実装としてまとめつつ、UIシェル・メタデータ・アップロード・レンダリングの責任はホストアプリが持ち続けられるよう設計しました。完全にヘッドレスにするのではなく、基本設定だけでもすぐに使えるが、必要に応じて機能を取り除き、置き換え、既存のサービス構造に合わせて接続できるエディタを目指しました。
機能追加にとどまらず、実際に他のアプリが持って使える公開サーフェスと運営フローまでを共に整理しました。
内部はFSD(Feature-Sliced Design)レイヤーで構成しました。ライティングUI、編集機能、純粋モデル、基本プリミティブをレイヤーごとに分離してあると、パッケージの抽出後でも公開APIをどこで切るか判断しやすかったからです。
この構造を事前に作っておいたおかげでcore、react、panda-primitivesといったエントリーポイントを自然に分けることができました。編集機能を維持しつつ、React依存のないロジックと基本UIの実装を分離して公開パッケージとして管理しやすい形を作ることができました。
アップロードAPI、リンクプレビュー、イメージレンダリングのようにサービスごとにポリシーが異なる部分はアダプタとして分離し、パッケージ内で固定しないようにしました。
アダプタを提供しなければそのツールバーアクションは自動で無効化されます。chaeditor/default-hostエントリーポイントは、、を呼び出す基本実装体を提供し、アップロード前の画像最適化(1600×1600px / 84%品質)も内蔵されています。
MarkdownRendererはサーバーレンダリングに適し、エディタサーフェスはクライアントコンポーネントで、アップロードとリンクプレビューはルートハンドラーやサーバーアクション側のポリシーと接続する形です。chaenに再統合するときもエディタサーフェスだけパッケージに置き換え、アップロードAPI・next/image基盤レンダリング・リンクプレビュー・プリミティブシェルはホスト側でそのまま維持できました。
ホストアプリのデザインシステムをエディタ内部まで一貫して適用するための構造です。、、、、、をスロットとして開けておき、ホストアプリが自身のコンポーネントに置き換えることができるようにします。
注入されなかったスロットはpanda-primitives基本実装でフォールバックします。MarkdownPrimitiveProviderとしてツリーの最上位に注入し、内部のどこからでもuseMarkdownPrimitives()で消費します。プリミティブを置き換えればエディタ内部のリンク/画像/数式ヘルパーとオーバーレイUIまで同じデザインシステム上で動作します。
スタイルの切り替えポイントはCSSカスタムプロパティとして開けておきました。Panda CSSトークンだけを開けておくとホストアプリがTailwind、Emotion、styled-components、vanilla-extractのような環境で再利用しにくいため、CSS変数を外部契約にしてPandaは基本実装体にのみ残しました。
ビルド成功だけでは十分ではありません。@/ エイリアスがビルド後に解釈されないか、型宣言ファイルが欠落しているか、ツリーシェイキングで内部依存性が意図せず削除される場合があります。実際のnpm tarballを展開し、一時コンシューマーがインポートする段階まで確認して、package exports、型宣言、CJS/ESMサーフェスが生きているかを確認する必要があります。
ファイル: scripts/verify-package-surface.mjs
テストグループは2つだけ運営します。
| グループ | 基準 | ワーカー |
|---|---|---|
node | ファイル上部 @vitest-environment node コメント、純粋ロジック | デフォルト設定 |
dom-ui | *.test.tsx、React UIとDOM配線 | デフォルト設定 |
SVG モック3種:
純粋なmarkdownヘルパーと選択ユーティルはnodeテストに送り、エディター · ツールバー · ビューアのようにDOMの相互作用が必要な領域だけをdom-uiに残しました。
Storybook 10 + Chromaticを接続し、PR単位のビジュアル差分でCSSの変更が他のサーフェスに波及するかを確認します。バージョンアップ時の version スクリプトでStorybookビルドとChromaticデプロイが自動で進行します。
Storybookは「コンポーネントの集まり」ではなく、エディター / ツールバー / レンダラー / 埋め込みワークフローで何が変わり、どの契約が維持されるかを比較するリファレンスとして構成しました。README、wiki、ホストプリセット文書も同じ流れで分けました。Storybookは視覚的状態と相互作用を迅速に確認する場とし、wikiは統合ガイドとリファレンスを英語/韓国語でまとめ、ホストプリセット文書は実際の接続例をすぐに使用できるように構成しました。
chaeditorはReactアプリとNext.jsアプリの両方で使えるように設計されました。そのためSVGアイコンも特定のバンドラー専用のコンポーネントに依存せず、生のSVG文字列を受け取ってランタイムで整理した後、インラインSVGとしてレンダリングする方法を選びました。ソース環境では柔軟でしたが、この選択のためにdevパスとdistパスが異なる場合のみ現れるバグが発生しました。
chaeditorをchaenに接続した直後、ツールバーのQuoteアイコンが見えなくなるという現象が発生しました。Storybookでは正常でしたが、実際のデプロイ環境でのみアイコンが消えるパターンでした。
StorybookはVite dev server上でソースファイルを直接読み取り、実際のホストアプリは dist/ をnpmパッケージとして参照します。アイコンコンポーネントはSVGファイルを ?raw で文字列として取得し、ランタイムで normalizeSvgMarkup()を経てインラインSVGとしてレンダリングします。
normalizeSvgMarkupにはSVGをアイコンサイズに合わせてスケールする正規化ステージがありました。
この正規表現はグローバル(gフラグ)としてSVG文字列全体に適用されます。quote.svgにはFigmaから出力されたclipPathの定義がありました。
グローバル正規表現が <rect width="24" height="24"> から と を削除して にし、clip領域が0×0になりアイコン全体が隠れてしまいました。
clipPathを含むSVGは quote.svg と github.svg の2つだけでした。github.svg も同じ理由で破損していましたが、リンクアイコンとして使用されていたため発見が遅れました。
修正は外部マッチングを <svg> の開くタグ1つに絞ることでした。
この修正をきっかけにclipPathケースを扱う回帰テストも追加しました。
chaenで既存のアーティクルを呼び出して編集すると、ツールバー機能1つだけを適用してもUIの変化が目に見えて遅延する問題がありました。
原因 1 — execCommandがドキュメント全体を交換
ツールバーアクションは選択範囲のみにマークダウンフォーマットを適用する単純な作業です。しかし内部実装が常に0から最後まで選択し、全ドキュメントをinsertTextとして渡していました。
document.execCommand('insertText') は非推奨のAPIですが、ブラウザのネイティブundoスタックに変更を登録できる唯一の方法です。5万文字のアーティクルでボールド処理を押すとブラウザがundoスタックに5万文字を記録し、5万文字を再度書き込むDOM mutationを同期的に処理します。
以前の値と新しい値の共通プレフィックスとサフィックスを計算して変更された区間のみを交換する方法に修正しました。
5万字のアーティクルでボールド処理をクリックすると、今は**text**10文字以内だけを置き換えます。execCommandのコストがO(n) → O(変更量)に減り、undoも実際に変わった部分だけを戻します。
原因2 — プレビューのレンダーが各入力ごとに同期実行
が呼ばれると、プレビューパネルも同期的に再レンダーされます。 (全体のドキュメントスキャン)、(カスタムブロックのパース)、(コードハイライト)はすべての変更ごとに同期的に実行される構造でした。
React.useDeferredValueでプレビュー専用の値を分けました。
React 19でuseDeferredValueはユーザーの入力のような緊急のアップデートを先に処理し、プレビューのレンダーを低い優先度に遅らせます。テキストエリアは即座に反応し、プレビューはReactが余裕のある時間で追いつきます。
核心は「小さなフォーマット動作に対して非常に大きなコストをかけていた」点でした。ドキュメントが長くなるほどこの種の問題はより明らかになるため、ツールバーtransformとプレビューレンダーの経路をまず軽量化する方向でパッチしました。今もまだ減らす余地は残っていますが、実際の編集フローを妨げる緊急のボトルネックはこの段階で迅速に整理して配信しました。
パッケージをオープンソースとして公開する際に最も注意を払ったのは、境界を明確に引くこととドキュメント化でした。拡張的な構造においてパッケージとホストアプリの責任が不明確だと、統合する側としてはどこを操作すればいいのか分かりにくいです。ホストアダプター、プリミティブレジストリ、CSSバリアブルコントラクトは、すべてその境界をコードで表現したものであり、READMEとストーリーブックはその境界を文書で説明したものです。
「chaen」に再度適用しながら、この境界が実際に維持されているかを確認でき、その過程で発見されたバグとパフォーマンスの問題をパッケージレベルで修正しました。
src/
├── widgets/editor/ # MarkdownEditor (最上位組合せ)
├── features/edit-markdown/ # toolbar / image / file / link / math / video / formatting
├── entities/editor-core/ # 純粋モデルユーティリティ (Reactなし)
├── shared/ # UIプリミティブ + 共有ライブラリ
├── core/ # 公開APIバレル (entities再エクスポート)
├── react/ # 公開APIバレル (features + widgets再エクスポート)
└── panda-primitives/ # 基本Panda CSS実装体type MarkdownEditorHostAdapters = {
uploadImage?: (args: { file: File; contentType; imageKind }) => Promise<string>;
uploadFile?: (args: { file: File; contentType }) => Promise<string>;
uploadVideo?: (args: { file: File; contentType }) => Promise<VideoEmbedReference>;
fetchLinkPreviewMeta?: (url: string) => Promise<LinkPreviewMeta>;
resolveAttachmentHref?: (href: string, contentType) => Promise<string>;
renderImage?: HostImageRenderer; // Next.js Imageなどカスタムイメージコンポーネント
};/api/files/api/videosButtonInputTextareaPopoverModalTooltipexport type MarkdownPrimitiveRegistry = Partial<{
Button: MarkdownButtonPrimitive; // forwardRef<HTMLButtonElement, ButtonProps>
Input: MarkdownInputPrimitive; // forwardRef<HTMLInputElement, InputProps>
Modal: MarkdownModalPrimitive; // ComponentType<ModalProps>
Popover: MarkdownPopoverPrimitive; // ComponentType<PopoverProps>
Textarea: MarkdownTextareaPrimitive; // forwardRef<HTMLTextAreaElement, TextareaProps>
Tooltip: MarkdownTooltipPrimitive; // ComponentType<TooltipProps>
}>;export const CHAEDITOR_THEME_VARIABLES = {
primary: '--chaeditor-color-primary',
primaryHover: '--chaeditor-color-primary-hover',
primaryContrast: '--chaeditor-color-primary-contrast',
primaryMuted: '--chaeditor-color-primary-muted',
primarySubtle: '--chaeditor-color-primary-subtle',
surface: '--chaeditor-color-surface',
surfaceMuted: '--chaeditor-color-surface-muted',
surfaceStrong: '--chaeditor-color-surface-strong',
text: '--chaeditor-color-text',
textSubtle: '--chaeditor-color-text-subtle',
muted: '--chaeditor-color-muted',
border: '--chaeditor-color-border',
borderStrong: '--chaeditor-color-border-strong',
focusRing: '--chaeditor-color-focus-ring',
error: '--chaeditor-color-error',
success: '--chaeditor-color-success',
overlayBackdrop: '--chaeditor-color-overlay-backdrop',
sansFont: '--chaeditor-font-sans',
sansJaFont: '--chaeditor-font-sans-ja',
monoFont: '--chaeditor-font-mono',
} as const;
```ts
import { createChaeditorThemeVars } from 'chaeditor/core';
const myTheme = createChaeditorThemeVars({
primary: '#3b82f6',
surface: '#ffffff',
text: '#18181b',
});
// → { '--chaeditor-color-primary': '#3b82f6', ... }
// → <div style={myTheme}> で適用npm pack で実際にデプロイされる .tgz を生成/.tmp/package-surface-smoke/) を生成core, react, default-host, panda-primitives) を実際にインポートtsc --noEmit)*.svg → ダミーReactコンポーネント (svg-component.tsx)*.svg?url → ダミーURL文字列 (svg-mock-url.ts)*.svg?raw → インラインSVG文字列 (Viteインラインプラグイン)// app-icons.tsx
import QuoteSvg from '@/shared/assets/icons/quote.svg?raw';
const rawSvgMarkup = resolveRawSvgMarkup(svgSource); // モジュール評価時に固定
const svgMarkup = React.useMemo(
() => scopeSvgMarkupIds(normalizeSvgMarkup(rawSvgMarkup), iconScopeId),
[assetSvgMarkup, iconScopeId],
);// 修正前
.replace(/\s(width|height)="[^"]*"/gi, '')<g clip-path="url(#clip0_403_3386)">
<path d="M0 7.99939V13.9994..." fill="currentColor"/>
<path d="M18.0002 3.99939..." fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_403_3386">
<rect width="24" height="24" fill="white"/> ← ここが問題
</clipPath>
</defs>height<rect/>// 修正後
.replace(/<svg\b[^>]*>/, svgTag => svgTag.replace(/\s(width|height)="[^"]*"/gi, ''))it('Under raw SVG markup with a clipPath, normalizeSvgMarkup must preserve clipPath element dimensions', () => {
const input = [
'<svg width="24" height="24" viewBox="0 0 24 24">',
'<g clip-path="url(#clip0)"><path fill="currentColor" /></g>',
'<defs><clipPath id="clip0"><rect width="24" height="24" fill="white"/></clipPath></defs>',
'</svg>',
].join('');
const result = normalizeSvgMarkup(input);
expect(result).toContain('width="24" height="24"'); // clipPath rect サイズ保護
expect(result).toContain('width="100%"'); // SVGルートは100%に置換
});// 修正前
textarea.setSelectionRange(0, textarea.value.length); // 全選択
document.execCommand('insertText', false, nextValue); // 全ドキュメント交換// 修正後
const prevValue = textarea.value;
const minLength = Math.min(prevValue.length, nextValue.length);
let changeStart = 0;
while (changeStart < minLength && prevValue[changeStart] === nextValue[changeStart]) {
changeStart += 1;
}
let changeEndPrev = prevValue.length;
let changeEndNext = nextValue.length;
while (
changeEndPrev > changeStart &&
changeEndNext > changeStart &&
prevValue[changeEndPrev - 1] === nextValue[changeEndNext - 1]
) {
changeEndPrev -= 1;
changeEndNext -= 1;
}
textarea.setSelectionRange(changeStart, changeEndPrev);
document.execCommand('insertText', false, nextValue.slice(changeStart, changeEndNext));onChangecollectMarkdownImagesparseRichMarkdownSegmentsrehype-pretty-codevalue// 修正前
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(value), // O(n) イメージ収集
}),
[adapters, value],
);// 修正後
const deferredValue = React.useDeferredValue(value);
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(deferredValue),
}),
[adapters, deferredValue],
);