コンテンツにスキップ

サイドノート作成

文章・Markdown・スクリーンショット・PDFに、右側の「サイドノート」で注釈を付けるツールです。サーバーはなく、静的ファイルを Cloudflare Pages に置いているだけです。開いた資料も、書いたコメントも、自分のパソコンの外に出ません。 処理はすべてブラウザの中(JavaScript)で行います。

このページは、その仕組みと、作るうえで判断したことの解説です。

全体の構成

ビルド工程のない素のHTML / CSS / JavaScript です。React などのフレームワークもバンドラーも使っていません。

public/
  index.html      画面
  js/             本体(編集・注釈・保存・書き出し・PDFモード)。12ファイルを番号順に読み込む
  md-parser.js    Markdown → ブロック配列(DOMに依存しない自前パーサー)
  style.css       画面・印刷の見た目
  themes.css      7種類のデザイン(CSS変数のセット)
  fonts/          Webフォント(woff2・fonts.css。Google Fontsから同梱)
  vendor/         pdf.js・DOMPurify・docx(同梱)

画面は左右2カラムです。左が本文(またはPDF本体)、右がサイドノートです。

本体を12ファイルに分けている(ビルド工程なし)

もともと4,000行を超える1つの app.js でしたが、機能ごとに public/js/ の12ファイルに分けました(00-core 状態と定数 → 10 無害化・貼り付け → 20 保存 → 30 MD・hotline書き出し → 40 docx → 50 印刷 → 60 開く・自動保存・Undo → 70 UIパネル → 80 ブロックとMarkdown取り込み → 90 書式ツールバー → 95 サイドノート配置 → 96 PDFモード → 99 起動時の初期化)。

バンドラーは使わず、index.html に <script> を番号順に並べているだけです。クラシックスクリプトは同じグローバルスコープを共有するので、ファイルをまたいで関数や変数をそのまま参照できます。分割は、元の行を機械的に切っただけで、内容は1行も変えていません(空行とコメントを除いて、連結すると元のファイルと一致することを確認しています)。

ひとつだけ、分割で壊れた点がありました。単一ファイルでは、関数宣言が巻き上げられるため、ファイルの先頭付近から「後ろで定義した関数」を呼べます。分けるとそれができず、起動時に走る呼び出し(最初の通し番号の振り直しなど)が ReferenceError になります。そこで、起動時の呼び出しを 99-init.js に集めました。起動時の呼び出し(文書の最初の通し番号、自動保存の有無の確認、書式ツールバーや「項番設定」の初期表示、Undo 履歴の初回セットなど)は、元の実行順のまま 99-init.js に集めました。

ここで1つ、順序が効く箇所がありました。画面下部の注記(見出しの上部余白など)は、#doc に段落がある状態で測った値を表示します。resetDoc() を 99-init.js に移すと、測るときに #doc が空で、上部余白が「1.1em」ではなく「0em」と表示されてしまいました。起動前後の画面の状態(DOM、各トグルの状態、注記の文字など)を、変更前の版と並べて比較して見つけた不具合です。このため、resetDoc() と、localStorage から自分の状態を読み込むもの(テーマ、メニュー設定など)は、宣言のすぐ隣に残しています。

ファイルの読み込み順は変えられません。 起動時に走る文が後ろのファイルの関数や変数に触れていないかは、npm test(test/load-order.test.js)が構文解析で検査します。index.html の <script> の並びも変えないでください。

画面の状態は「DOM」と「ノートの表」で持つ

状態の持ち方は、次の2つに分けています。

何を どこに持つか
本文(段落・画像・表・書式) contenteditable な #doc のDOMそのもの
コメントの中身 notesByAnchor(Map:アンカーID → ノートの配列)

注釈を付けた範囲は、本文の中で <span class="note-anchor" data-anchor-id="a3"> に包みます。コメントの文字・色・返信はDOMではなく notesByAnchor に入れ、アンカーIDで結びます。1つのアンカーに複数のノートを配列で持たせているので、そのまま返信スレッドになります。

本文のDOMと、ノートの表と、サイドノートの関係。注釈した一文のアンカーIDで、本文とノートが結ばれる。番号と配置の処理が、両方を読んで右側のカードを描く。
本文のDOMと、ノートの表と、サイドノートの関係。注釈した一文のアンカーIDで、本文とノートが結ばれる。番号と配置の処理が、両方を読んで右側のカードを描く。

この図のテキスト(DSL)

RelaGrid に貼り付けると、同じ図を描けます。

grid 4x2

node doc    A1 icon=file     color=blue   "本文(DOM)"
node anchor B1 icon=pin      color=orange "注釈した一文"
node notes  C1 icon=database color=green  "ノートの表"
node layout C2 icon=refresh  color=purple "番号と配置"
node card   D2 icon=chart    color=slate  "右のサイドノート"

doc -> anchor "含む"
anchor -> notes "アンカーIDで対応"
anchor -> layout "登場順に番号"
notes -> layout "コメント"
layout -> card "描画"

note notes "コメント・色・返信" pos=top

注釈した範囲はロックする

注釈済みの範囲は contenteditable="false" にします。後から原文を書き換えてしまい、コメントと食い違う事故を防ぐためです。範囲選択が注釈済みの部分や、画像・表・区切り線にかかっているときは、太字・下線・ノート追加を受け付けません。

通し番号とサイドノートの配置

編集のたびに renumberAndLayout() が走り、次の順で処理します。

  1. DOM内の .note-anchor を登場順に集め、1, 2, 3… と番号を振り直す
  2. 文書に存在しないIDのノートを notesByAnchor から捨てる(画像ごと消えた場合の取りこぼし対策)
  3. 右側のカードを、対応する一文の縦位置に揃えて置く

カード同士が重なる場合は、直前のカードの下端+20pxまで押し下げます。位置は「本文の上端からの相対値」で決めるので、窓幅を変えたときも再計算(150msのデバウンス)で追従します。

Markdown の取り込みと書き出し

AIチャットが出力する Markdown を貼り付けて使うことを前提に、基本構文(見出し・段落・太字/斜体/取消線・リンク・リスト・引用・表・区切り線・コードブロック・画像)だけを扱う自前パーサー md-parser.js を作りました。CommonMark への完全準拠は狙っていません。

パーサーは2段階です。

  1. parseMarkdownBlocks():文書を {type: "heading" | "paragraph" | "li" | "table" …} のブロック配列にする。DOMには触らない
  2. inlineToHtml():段落内の記法をHTML文字列にする

逆方向(本文のDOM → Markdown)は js/30-export-md-hotline.js の docToMarkdown() が担当します。注釈・配置・インデントは Markdown で表せないため、書き出しには含まれません。

インライン変換の順序

inlineToHtml() は、順序で安全を確保しています。

  1. まず HTMLエスケープ(& < >)をする
  2. `コード` を先に確定し、中身を退避する(後段の * などの対象から外す)
  3. 太字・斜体・取消線・リンクの順に変換する
  4. 退避したコードを戻す

「先にエスケープしてから、許可したタグだけを足す」ので、入力に書かれた <script> はただの文字になります。

属性の中は別扱い(2026-10に修正)

「< と > を逃がせば安全」では足りない場所が、1つありました。リンクの href="…" の中です。" を逃がしていなかったため、次のような Markdown で属性を抜け出せました。

[x](http://a"onmouseover="alert`1`)

貼り付けた瞬間に属性が増え、マウスを乗せるとスクリプトが動きます。そこで、href に入れる前に " と ' をエスケープしています。HTMLに文字列を埋め込む場所(本文・属性)ごとに、必要なエスケープが違うということです。hotline 用の書き出し(生HTML)でも同じ理由で、属性用の escapeAttr() を別に用意しました。

退避したコードの目印も、同じ種類の問題を避けるため、普通の文字列ではなく \u0000 で挟んだ番号にしています(以前は CODE0 という通常の文字列で、本文に同じ並びがあると壊れました)。

Shift+Enter の改行を残す

段落の途中の改行は、保存して開き直すと消えがちです。空行(=段落の区切り)とは違う意味なので、書き出しでは \n、取り込みでは <br> に戻します。表のセルの中では、行を割らないよう <br> と書きます(GitHub と同じ記法)。

保存形式の使い分け

入口(貼り付け・.md、.json、.pdf)から本文とノートに取り込み、出口(.md、.json、.docx、.pdf、hotline用)へ書き出す流れ。
入口(貼り付け・.md、.json、.pdf)から本文とノートに取り込み、出口(.md、.json、.docx、.pdf、hotline用)へ書き出す流れ。

この図のテキスト(DSL)

RelaGrid に貼り付けると、同じ図を描けます。

grid 5x5

zone A2:A4 "入口" color=slate
zone E1:E5 "出口" color=slate

node paste  A2 icon=file   color=slate  "貼り付け・.md"
node jsonin A3 icon=folder color=slate  ".json(開く)"
node pdfin  A4 icon=file   color=slate  ".pdf(開く)"
node app    C3 icon=box    color=blue   "本文+ノート"
node outmd  E1 icon=code   color=green  ".md(本文のみ)"
node outjs  E2 icon=folder color=green  ".json(すべて)"
node outdoc E3 icon=file   color=green  ".docx(コメント付き)"
node outpdf E4 icon=mail   color=green  ".pdf(印刷)"
node outhot E5 icon=globe  color=green  "hotline用(生HTML)"

paste -> app "取り込み"
jsonin -> app "復元"
pdfin -> app "PDFモード"
app -> outmd
app -> outjs
app -> outdoc
app -> outpdf
app -> outhot
形式 用途 含まれるもの
.json 作業の続き・共有相手とのやり取り 本文・注釈・書式・画像・元PDF(すべて1ファイル)
.md AIチャットとの往復 本文のみ(注釈・書式は含まれない)
.docx Word で開く 本文。サイドノートは Word のコメントになる
.pdf 印刷・共有 本文+サイドノート
.md.txt hotline サイトの書面ページ 生HTML(字下げ・ぶら下げ・ノート)

画像とPDF本体は data URLとして .json に内包します。ファイルが1つで完結するので、相手に渡すのが簡単です。その代わり、注釈の対象にした資料そのものがファイルに入る点には注意が必要です。個人情報を含む .json を、誤ってコミットしないようにしてください。

.json は「信用できない入力」として扱う

.json は共有相手とやり取りするため、テキストエディタで自由に書き換えられます。本文の docHTML を、そのまま innerHTML に戻すと、<img src=x onerror=…> を仕込まれて「開く」を押した瞬間にスクリプトが動きます。

そこで、読み込み時に DOMPurify で許可リスト方式のサニタイズを通します。このアプリが実際に作るタグと属性だけを許可し、URLは http(s)・mailto・ページ内リンク・data:image/…;base64 に限ります。同梱の DOMPurify が読み込めなかった場合は、無害化できないまま続行せず、読み込み自体を拒否します(フェイルクローズ)。自動保存からの復元も、同じ経路を通ります。

PDFモード

「開く」で .pdf を選ぶと、そのPDFに直接サイドノートを付けるモードになります。

  • 描画は pdf.js。ページごとに canvas・テキストレイヤー・注釈レイヤーを重ねる
  • 文字が選択できるPDFは、範囲選択で注釈位置を決める
  • スキャンなど文字が1つもないページは、**クリック(点)/ドラッグ(矩形)**で決める。文字の有無はページごとに自動判定する
  • 注釈の位置は、表示倍率に依存しない「ページ空間」(scale=1)の座標で保存する。窓幅が変わっても、別の端末で開いても、同じ場所に出る

右側のカード、返信スレッド、色選択は、本文モードと同じ仕組みを共通で使っています。モードごとに違うのは「どこにノートを固定するか」だけなので、番号の振り方(renumberAndLayoutText / renumberAndLayoutPdf)と印刷の組み立てだけを分けました。PDFモードでは、番号は「ページ → ページ内の縦位置」の順で振ります。

元のPDFは data URLのまま .json に内包します。

印刷(PDF化)

独自にPDFを組み立てず、ブラウザの印刷機能を使います。印刷用のDOM(#printDoc)を別に組み立て、window.print() に渡して、保存先で「PDFに保存」を選ぶ方式です。

  • 本文モード:サイドノートを、対応する一文のすぐ後ろにインラインで埋め込み、CSSの float で段落の右余白に逃がす(Tufte CSSとして知られる余白注釈の手法)。画面と違い、ページをまたぐレイアウトでは絶対座標が使えないため
  • PDFモード:描画済みの canvas をそのまま画像にして、ページ画像に対する絶対座標でノートを置く
  • 白黒:画像を Canvas で灰色に変換し、body に .print-bw を付けて文字・線・番号を黒にする。Chrome の印刷ダイアログの「カラー」設定は「PDFに保存」では出ないため、色はこちらで決めている

外部URLの画像は、押すまで読み込まない

Markdown の ![](https://…) や、共有された .json の <img src="https://…"> を、開いた瞬間に読み込むと、画像の置き場所のサーバーに「いつ・どのIPで開いたか」が伝わります。内容が外に出ない作りのツールとしては、見過ごせない通信です。

Markdownは、エスケープして変換する。.jsonと自動保存は、許可リストで無害化する。どちらも外部画像のsrcを外し、URLと読み込むボタンだけを表示する。通信は、ボタンを押した時だけ起きる。
Markdownは、エスケープして変換する。.jsonと自動保存は、許可リストで無害化する。どちらも外部画像のsrcを外し、URLと読み込むボタンだけを表示する。通信は、ボタンを押した時だけ起きる。

この図のテキスト(DSL)

RelaGrid に貼り付けると、同じ図を描けます。

grid 5x3

node mdin   A1 icon=file     color=slate  "Markdown"
node jsonin A3 icon=folder   color=slate  ".json・自動保存"
node parser B1 icon=code     color=blue   "エスケープして変換"
node purify B3 icon=shield   color=blue   "許可リストで無害化"
node strip  C2 icon=lock     color=orange "外部画像のsrcを外す"
node ph     D2 icon=box      color=orange "URLと読み込むボタン"
node net    E2 icon=globe    color=red    "通信が発生"

mdin -> parser
jsonin -> purify
parser -> strip
purify -> strip
strip -> ph "表示のみ"
ph -> net "押した時だけ" style=dashed color=red

note strip "開いただけでは通信しない" pos=top

そこで、外部URLの画像は次のように扱います。

  • 取り込み時は <img> に src を付けず、URLを data-external-src に退避する。画像の代わりに、URLと「読み込む」ボタンの枠を出す
  • 利用者がボタンを押した時だけ src を設定して読み込む
  • .json・自動保存の復元時は、DOMPurify のフックで src を data-external-src に退避する。悪意ある .json に src が直接書かれていても、読み込まれない
  • 同梱の画像(data:image/…)は、これまでどおり即時に表示する
  • 未読み込みの外部画像は、印刷(PDF化)にも出さない(通信が起きないように)
  • .md に書き出す時は、元のURLをそのまま書く

読み込み済みだった画像も、保存して開き直すと「未読み込み」に戻ります。開くたびに確認することになりますが、安全側に倒しています。

元に戻す(Undo / Redo)

ブラウザ標準の Undo は、JSが直接DOMを組み立てた操作(画像貼り付け、表挿入、ノート追加)には効きません。そこで、保存(serializeProject())と同じ形のスナップショットを履歴として積む方式にしました。

記録のタイミングは、既存の自動保存に相乗りさせています。状態を変える操作は、最後に必ず自動保存を呼ぶので、記録漏れが起きません。連続入力は800msのデバウンスでまとまり、1文字ごとに履歴が増えることもありません。直前と同じ内容(savedAt だけが違う場合)は積みません。タイトル欄などの通常の入力欄にフォーカスがあるときは、ブラウザ標準の Undo に任せます。

デザイン

本文の見た目は7種類から切り替えられます。themes.css に、デザインごとのCSS変数のセットを置き、<html data-theme="…"> を書き換えるだけで切り替えます。本文のDOMは変わらないので、中身(テキスト・注釈)は何も変わりません。ダークモードは別のフラグで、本文エリアの色だけを暗くします(アプリ本体の見た目や印刷には影響しません)。

外部通信をなくす

書体は当初 Google Fonts から読み込んでいましたが、2026年10月にリポジトリへ同梱する形に変えました。「内容は外部に出ない」と謳うツールが、ページを開くたびに外部サーバーへアクセスするのは筋が悪いためです。あわせて、CSP(_headers)で script-src 'self' と connect-src 'self' data: blob: に絞り、仮にスクリプトが紛れ込んでも外へ送れないようにしています。

日本語フォントは1書体が数MBあるため、Google Fonts と同じく unicode-range で細かく分割した woff2(約1,000ファイル・約30MB)をそのまま置いています。ブラウザは、文書に出てくる文字を含む分割だけを取得します。同梱の代償は、リポジトリが重くなることです。

テスト

テストは2種類です。

  • 単体テスト(npm test):md-parser.js の変換と、JSファイルの読み込み順(構文解析で検査)。ブラウザは要らず、数秒で終わる
  • ブラウザテスト(npm run test:e2e、Playwright):本番と同じCSPを付けたサーバーでアプリを動かし、起動・Markdown取り込み・サイドノート・自動保存・各形式の書き出し・PDFモード・悪意ある入力・外部画像を確認する。さらに、すべてのテストで「コンソールエラー・CSP違反・外部への通信が出ていないこと」を終了時に検査する

ブラウザテストを足してすぐ、実際の不具合が見つかりました。app.js を12ファイルに分けた時、PDFモードだけが壊れていました。pdf.js の読み込み(import("./vendor/pdfjs/pdf.min.mjs"))は、ページではなくスクリプトファイルの場所を基準に解決されるため、js/ の下へ移ったことで /js/vendor/… を探して404になっていたのです。単体テストと、通常の画面操作の手動確認では気づけず、PDFを開く操作を自動で通すテストが捕まえました(../vendor/… に直して解消)。ファイルの置き場所を変えたときは、相対パスで資源を読んでいる箇所に注意が必要です。

限界と使うときの注意

  • 注釈はブラウザの execCommand と contenteditable に頼っています。ブラウザの挙動差(特に Chrome 以外)で、細かな編集の挙動が変わることがあります
  • 自動保存は localStorage です。PDFを開いている場合は、容量超過で保存されないことがあります(失敗は表示されません)。区切りのよいところで .json に保存してください
  • .md への書き出しでは、注釈・書式(配置・インデントなど)が消えます
  • hotline 用の書き出しは、画像・表・箇条書きを対象にしていません

姉妹ツール

スキャン書類を、墨消ししたうえで検索できるPDFにしたい場合は、ローカルアプリの scan-ocr を使います。このツールが「読んで注釈を付ける」ものだとすると、scan-ocr は「資料を安全に加工する」側の道具です。

デザインシステム

掲載予定です。