はじめに
ブラウザの右側に常駐させて、調べ物をしながらそのまま Markdown でメモが取れる Chrome / Edge 拡張機能 SideMemo を作りました。データはすべてユーザー端末の IndexedDB にローカル保存され、外部送信は一切ありません。
Chrome ウェブストアで公開しています:
なぜ作ったのか — ネットサーフィン中の「ちょっと置いておきたい」を解決したかった
きっかけは、普段ブラウザを使っていて何度もぶつかる小さなストレスでした。
- OpenAPIやOpenRouterなどの API Key やトークン を一時的に控えておきたい。でも
.txtを開くのは大げさ - 後で別のページで使い回したい 検索ワード(エラーメッセージのコピペ、英語のキーワード、製品名の正式表記など)をどこかに置いておきたい
- 調べ物中に「この URL あとで見返したい」となる場面で、ブックマークほど永続化したくはない
- そういうメモを取るたびに、エディタや別アプリにフォーカスが移ってブラウジングのリズムを切りたくない
つまり「タブを離れず、書いた瞬間に消えず、機密寄りなのでクラウドに上げたくない」という、地味だけど毎日発生するメモ需要に応えるツールが欲しかった、というのが出発点です。Notion / Obsidian は重すぎ、メモ帳アプリはウィンドウ切替が発生する。ブラウザのサイドパネルならその間を埋められそう、というのが SideMemo のコアアイデアです。
API Key のようなセンシティブな情報を扱う前提なので、外部送信ゼロ・IndexedDB ローカル保存・host_permissions 不要という制約は最初から動かさないことにしました。
技術的な軸
実装の方針として、次の 3 つを軸にしました。
-
Manifest V3 +
chrome.sidePanel— Chrome 114 で追加されたサイドパネル API をフル活用 -
真の WYSIWYG な Markdown 編集 —
@uiw/react-md-editorのようなモード切替型ではなく、入力中に整形される Milkdown を採用 -
ローカル完結のプライバシー設計 —
host_permissionsゼロ、外部 fetch ゼロ、テレメトリゼロ
この記事では、UI のスクショと実装の要点をセットで紹介します。
動作デモ
ライト / ダーク両テーマに対応。テーマは「自動 / ライト / ダーク」から選べて、自動はブラウザの設定に追従します。
| ライトテーマ | ダークテーマ |
|---|---|
![]() |
![]() |
「+ ページ添付」ボタンを押すと、現在アクティブなタブの URL / タイトル / favicon を取得してメモに紐付けます。明示操作のときだけ取り込む設計で、メモを作っただけで勝手にページを記録することはしません。
サイドパネル幅が狭いとき(< 400px)は自動で 1 ペインのタブ表示に切り替わります。
設定画面ではレイアウトモード、テーマ、フォントサイズ、自動保存遅延、タグ管理、JSON エクスポート/インポートまで一通りカバーしています。
技術スタック
| 領域 | 採用技術 |
|---|---|
| 拡張機能規格 | Manifest V3 |
| UI | React 19 + TypeScript (strict) |
| ビルド | Vite + @crxjs/vite-plugin
|
| Markdown エディタ |
Milkdown (@milkdown/crepe) |
| データベース | IndexedDB (Dexie.js) |
| 全文検索 | Fuse.js |
| サニタイズ | DOMPurify |
| 配布 | Chrome ウェブストア |
選定で迷いどころだったのが Markdown エディタです。当初は @uiw/react-md-editor を考えていたのですが、これは「edit / preview / live」の モード切替型 で、要件である「書きながらリアルタイムに整形される真の WYSIWYG」が満たせませんでした。Milkdown は ProseMirror ベースで、# を打った瞬間に見出しに、**foo** を打ったら太字に化けるタイプのエディタです。
アーキテクチャ
全体像はこんな感じです。
ポイントは 3 つ。
- UI 層から Dexie を直接叩かない — すべて Repository 層を経由
-
Service Worker はステートレス — MV3 では SW が非アクティブ化されるため、永続状態は IndexedDB、SW→Panel の一時受け渡しは
chrome.storage.session -
ホスト権限ゼロ — ページコンテンツには触れず、
chrome.tabs.queryで URL / タイトル / favicon だけを取得
manifest.json
実物の manifest.json がそのまま設計書になっています。
{
"manifest_version": 3,
"name": "SideMemo",
"version": "0.1.0",
"description": "Markdown対応のサイドパネル型メモ拡張機能",
"permissions": [
"sidePanel",
"storage",
"tabs",
"contextMenus",
"downloads"
],
"side_panel": {
"default_path": "src/sidepanel/index.html"
},
"background": {
"service_worker": "src/background/service-worker.ts",
"type": "module"
},
"action": {
"default_title": "SideMemo を開く",
"default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png" }
},
"commands": {
"_execute_action": {
"suggested_key": { "default": "Ctrl+Shift+M", "mac": "Command+Shift+M" },
"description": "SideMemo を開く"
}
}
}
各権限の最小化方針は次のとおりです。
| 権限 | 用途 | 備考 |
|---|---|---|
sidePanel |
UI 全体を chrome.sidePanel で表示 |
必須 |
storage |
設定永続化 + SW→Panel の一時中継 (storage.session) |
|
tabs |
アクティブタブの URL / タイトル / favicon 取得 | コンテンツには触れない |
contextMenus |
右クリックの 2 項目登録 | |
downloads |
エクスポート JSON の保存 | |
host_permissions |
要求しない | ページコンテンツに触れないため不要 |
実装で工夫した点
1. Service Worker をステートレスに保つ
MV3 の Service Worker は 30 秒ほどで非アクティブ化されます。グローバル変数で状態を持つと再起動でロストするので、SW 側のコードは「都度初期化される」前提で書きます。
// src/background/service-worker.ts (抜粋)
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true })
.catch((error) => console.error("Failed to set side panel behavior", error));
setupContextMenus();
});
// SW は再起動するため onStartup でも確実に登録する
chrome.runtime.onStartup.addListener(() => {
setupContextMenus();
});
onInstalled だけだとブラウザを再起動した次のセッションでメニューが消えるケースがあるため、onStartup でも登録しておくのがコツです。
Manifest V3 の Service Worker は非アクティブ化されます。グローバル変数や setInterval で状態を保持しないでください。永続状態は IndexedDB / chrome.storage に置き、イベント駆動で書き直せる形に保ちます。
2. 右クリック → 選択テキスト挿入の取りこぼし対策
「選択テキストを SideMemo に挿入」を実装するとき、サイドパネルがまだ開いていないケースをどう扱うかが課題でした。
そこで chrome.runtime.sendMessage と chrome.storage.session の 二段構えにしています。
// SW 側
const payload: PendingInsertion = { text, capturedAt: Date.now() };
await chrome.storage.session.set({
[STORAGE_PENDING_INSERTION]: payload,
});
if (tab?.windowId !== undefined) {
await chrome.sidePanel.open({ windowId: tab.windowId });
}
// 既に開いていればメッセージで即時挿入する。
// 受信側不在時の例外は無視する (storage 経由で拾われる)。
chrome.runtime
.sendMessage({ type: MESSAGE_INSERT_TEXT, text, capturedAt: payload.capturedAt })
.catch(() => {/* receiver 不在時は無視 */});
サイドパネル側はマウント時に chrome.storage.session を読みに行き、未消化のテキストがあれば取り込みます。storage.session はブラウザ再起動で消える一時領域なので、古いペイロードが残り続ける心配がありません。
3. UI から Dexie を直接叩かない
将来クラウド同期に差し替えられるよう、UI 層は Repository 層 だけを見るようにしています。
// src/sidepanel/lib/db/notesRepo.ts (抜粋)
export async function listNotes(): Promise<Note[]> {
// ピン留め優先 → 更新日時降順
const notes = await db.notes.orderBy("updatedAt").reverse().toArray();
return notes.sort((a, b) => {
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
return b.updatedAt - a.updatedAt;
});
}
export async function createNote(input: CreateNoteInput = {}): Promise<Note> {
const now = Date.now();
const content = input.content ?? "";
const note: Note = {
id: uuidv4(),
title: extractTitle(content),
content,
tagIds: input.tagIds ?? [],
pinned: 0,
createdAt: now,
updatedAt: now,
};
await db.notes.add(note);
return note;
}
ハマりポイントとして、pinned は boolean ではなく number(0 / 1) にしています。
Dexie / IndexedDB は boolean をインデックスできません。「ピン留め優先で並べたい」のようにソートキーに使うフィールドは number で持ちましょう。
4. タイトルはユーザー入力ではなく派生値
メモにタイトル欄は存在しません。本文 1 行目の # 見出し を抽出して自動でタイトルにしています。
// src/sidepanel/lib/markdown/extractTitle.ts
const HEADING_PATTERN = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/;
export function extractTitle(content: string): string {
if (!content) return "無題";
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const match = HEADING_PATTERN.exec(line);
if (match) {
const title = match[1].trim();
return title.length > 0 ? title : "無題";
}
// 最初の非空行が見出しでなければ「無題」扱い
return "無題";
}
return "無題";
}
Note.title フィールドはこの抽出結果のキャッシュで、updateNote のたびに extractTitle で更新されます。一覧画面のソート / 検索は IndexedDB のインデックス越しに走るので、毎回パースするより速いです。
5. Milkdown は「ノート切替時にアンマウント」が肝
Milkdown (Crepe) は defaultValue がマウント時にしか効かない タイプのエディタです。note.id が変わったときに中身を入れ替えたかったら、コンポーネント自体を作り直すのが一番ラクでした。
// src/sidepanel/components/editor/MilkdownEditor.tsx
useEffect(() => {
const root = containerRef.current;
if (!root) return;
let destroyed = false;
const crepe = new Crepe({
root,
defaultValue: initialValueRef.current,
});
void crepe.create().then(() => {
if (destroyed) {
void crepe.destroy();
return;
}
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
onChangeRef.current(markdown);
});
});
onReadyRef.current?.(crepe);
});
return () => {
destroyed = true;
void crepe.destroy();
};
}, []);
呼び出し側は React の鉄板パターンで key にノート ID を渡してアンマウント / 再マウントを強制します。
{selectedNote && (
<EditorContainer
key={selectedNote.id}
note={selectedNote}
tags={tags}
onRequestDelete={setPendingDelete}
/>
)}
6. 自動レイアウト切替は useSyncExternalStore
サイドパネル幅 400px をしきい値に 2 ペイン↔1 ペインを切り替える部分は、window.resize を useSyncExternalStore で購読しました。useEffect + useState で書くより、レンダー中に最新値が読めて素直です。
// src/sidepanel/hooks/useLayoutMode.ts
const NARROW_THRESHOLD = 400;
export function useLayoutMode(): EffectiveLayout {
const settings = useSettings();
const width = useSyncExternalStore(subscribeWindow, getWindowWidth, () => NARROW_THRESHOLD);
if (settings.layoutMode === "two-pane") return "two-pane";
if (settings.layoutMode === "one-pane") return "one-pane";
return width >= NARROW_THRESHOLD ? "two-pane" : "one-pane";
}
7. ホスト権限ゼロでページ情報を取る
「+ ページ添付」は host_permissions を一切要求しません。tabs 権限だけで chrome.tabs.query を呼べば、URL / タイトル / favicon URL までは取れます。ページのコンテンツや DOM には触れないことを設計で保証しています。
// src/sidepanel/lib/chrome/tabs.ts
export async function captureActiveTab(): Promise<PageRef | null> {
try {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (!tab) return null;
return {
url: tab.url ?? "",
title: tab.title ?? "",
favicon: tab.favIconUrl,
capturedAt: Date.now(),
};
} catch (error) {
console.warn("captureActiveTab failed", error);
return null;
}
}
ストア審査の権限説明でも「ページコンテンツの読み書きは行わない」と明記でき、レビューはスムーズでした。
ビルド・配布フロー
@crxjs/vite-plugin のおかげで、vite.config.ts はびっくりするほど短く済みます。
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { crx } from "@crxjs/vite-plugin";
import manifest from "./manifest.json" with { type: "json" };
export default defineConfig({
plugins: [react(), crx({ manifest })],
server: {
port: 5173,
strictPort: true,
hmr: { port: 5173 },
},
});
CRXJS は Service Worker / Side Panel / コンテンツスクリプトの HMR までやってくれるので、開発中は npm run dev を回しっぱなしで OK。chrome://extensions/ で dist/ を読み込むだけで、UI の更新が即座に反映されます。
ストア提出用の zip は archiver で固める軽い Node スクリプトに任せています。
npm run build # tsc -b && vite build → dist/
npm run pack # dist/ を release/sidememo-<version>.zip に固める
npm run release # build + pack をまとめて
zip の中身は dist/ の 直下(manifest.json がトップにある形)にします。Chrome ウェブストアはこの形式を要求するので、archive.directory(distDir, false) の false(= ルートに展開)を忘れずに。
GitHub
GitHubにソースコードを公開しています。





