1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発】ブラウザのサイドパネルで動く Markdown メモ「SideMemo」を 作った話【Chrome拡張機能】

1
Last updated at Posted at 2026-04-29

はじめに

ブラウザの右側に常駐させて、調べ物をしながらそのまま Markdown でメモが取れる Chrome / Edge 拡張機能 SideMemo を作りました。データはすべてユーザー端末の IndexedDB にローカル保存され、外部送信は一切ありません。

Chrome ウェブストアで公開しています:

image.png

なぜ作ったのか — ネットサーフィン中の「ちょっと置いておきたい」を解決したかった

きっかけは、普段ブラウザを使っていて何度もぶつかる小さなストレスでした。

  • 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 のスクショと実装の要点をセットで紹介します。

動作デモ

ライト / ダーク両テーマに対応。テーマは「自動 / ライト / ダーク」から選べて、自動はブラウザの設定に追従します。

ライトテーマ ダークテーマ
image.png image.png

「+ ページ添付」ボタンを押すと、現在アクティブなタブの URL / タイトル / favicon を取得してメモに紐付けます。明示操作のときだけ取り込む設計で、メモを作っただけで勝手にページを記録することはしません。

image.png

サイドパネル幅が狭いとき(< 400px)は自動で 1 ペインのタブ表示に切り替わります。

image.png

設定画面ではレイアウトモード、テーマ、フォントサイズ、自動保存遅延、タグ管理、JSON エクスポート/インポートまで一通りカバーしています。

image.png

技術スタック

領域 採用技術
拡張機能規格 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** を打ったら太字に化けるタイプのエディタです。

アーキテクチャ

全体像はこんな感じです。

image.png

ポイントは 3 つ。

  1. UI 層から Dexie を直接叩かない — すべて Repository 層を経由
  2. Service Worker はステートレス — MV3 では SW が非アクティブ化されるため、永続状態は IndexedDB、SW→Panel の一時受け渡しは chrome.storage.session
  3. ホスト権限ゼロ — ページコンテンツには触れず、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.sendMessagechrome.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;
}

ハマりポイントとして、pinnedboolean ではなく 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.resizeuseSyncExternalStore で購読しました。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にソースコードを公開しています。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?