Web に手書きノート機能を載せるとき、
addEventListener("mousedown", ...)で書き始めると Apple Pencil の筆圧が全部捨てられる、touchstartだけで書き始めると マウスでテストできない、PointerEventを使っても 240 Hz でサンプリングしている iPad のサブフレームを取りこぼす、という三段の罠がある。300 行のブラウザ手書きノートを書きながら、その三段全部を解いた話。
🌐 デモ: https://sen.ltd/portfolio/canvas-notebook/
📦 GitHub: https://github.com/sen-ltd/canvas-notebook
なぜ MouseEvent / TouchEvent を捨てるか
歴史的な経緯で、Web の入力イベントは 3 系統に分かれている:
-
MouseEvent(mousedown/mousemove/mouseup) — マウス専用 -
TouchEvent(touchstart/touchmove/touchend) — タッチ専用、マルチタッチ対応 -
PointerEvent(pointerdown/pointermove/pointerup) — 上記を統一する後発の API
PointerEvent は マウス・タッチ・スタイラスを同じ event オブジェクトで扱える。e.pointerType で "mouse" / "touch" / "pen" を見分け、e.pressure で筆圧、e.tiltX / e.tiltY で傾き、e.twist で軸の回転 (Apple Pencil 2 以降は対応していない) まで取れる。
加えて setPointerCapture(pointerId) で 画面外にドラッグしても release イベントが拾える。mousemove ベースだと、ボタンを押したまま window を出るとイベントが来なくなって「描画中の状態」が壊れる、というあるあるバグが PointerEvent では起きない。
e.pressure の落とし穴
PointerEvent.pressure は 0〜1 の正規化値を返すが、デバイスによって値の意味が違う:
| pointerType | pressure の挙動 |
|---|---|
| mouse | 押してる間 0.5 固定、離すと 0
|
| touch (iOS) |
force を返す (3D Touch 廃止後も内部 API は残っている) |
| touch (Android) | 多くの端末で 0 固定 |
| pen (Apple Pencil) | 0.0〜1.0 の連続値、240 Hz でサンプリング |
| pen (Surface Pen / Wacom) | 0.0〜1.0 の連続値、120〜240 Hz |
つまり「e.pressure を読んで線の太さに掛ける」だけだと マウスで描いたとき太さが半分になる ということ。fallback が必要:
export function pressureToWidth(pressure, baseWidth, options = {}) {
const { min = 0.4, max = 1.6, fallback = 0.5 } = options;
const p = pressure > 0 ? pressure : fallback;
// [0, 1] → [min, max] に線形マップ、out-of-range は clamp
const factor = min + (max - min) * Math.max(0, Math.min(1, p));
return baseWidth * factor;
}
pressure === 0 のときだけ fallback を使う (押下していない / 検出不能を意味する)。0.001 でも来ていれば実値として扱う — そうしないと、低圧で書く iPad ユーザーが fallback と本物の境界をまたぐたびにストロークの太さが急変する。
ユニットテストで境界条件を全部固定:
test("pressureToWidth uses fallback only for pressure === 0", () => {
// 0.001 → 実値、ほぼ最小幅 (min × base = 4)
const w = pressureToWidth(0.001, 10);
assert.ok(w < 10);
assert.ok(w > 4);
});
test("pressureToWidth clamps out-of-range input", () => {
// 一部のドライバが pressure > 1 を返すバグ報告がある。crash させない
assert.equal(pressureToWidth(99, 10), 16);
assert.equal(pressureToWidth(-1, 10), 10); // fallback path
});
getCoalescedEvents() で取りこぼしゼロ
ここが本記事の核。Apple Pencil は 240 Hz でサンプリングする。一方ブラウザの requestAnimationFrame は 60 Hz。普通に pointermove を聞いていると、1 frame の間に OS が捕まえた 4 回分のサンプルのうち最後の 1 回しか拾えない。素早く線を引くと、サンプル間の距離が 8〜16 px になり、その間の curve が直線で繋がれて見栄えが悪い。
PointerEvent.getCoalescedEvents() は 過去 1 frame で coalesce された全サブサンプルを返す:
function onPointerMove(e) {
if (state.activePointerId !== e.pointerId) return;
// フォールバック: 未対応 / 空配列 を返すブラウザでは e 自身を 1 サンプル扱い
let samples = [e];
if (typeof e.getCoalescedEvents === "function") {
const coalesced = e.getCoalescedEvents();
if (coalesced && coalesced.length > 0) samples = coalesced;
}
for (const s of samples) {
state.active.points.push(eventToPoint(s));
}
redraw();
}
これで Apple Pencil で速く描いても 240 Hz 全部の点が記録される。同じ frame 内なので画面更新は 60 Hz でいいが、データとして失われない。
注意点: 合成イベント (テストで dispatchEvent(new PointerEvent(...))) では getCoalescedEvents() が 空配列を返す ので、length > 0 のガードが要る (これを忘れて画面に何も描かれない 1 時間を溶かした)。
RDP で点列を間引く
240 Hz × 数秒のストロークは数百〜数千点になる。直線部分は 2 点で十分なので、ストローク完了時に Ramer-Douglas-Peucker で間引く:
export function simplifyStroke(points, tolerance) {
if (points.length < 3) return points.slice();
const t2 = tolerance * tolerance;
const keep = new Array(points.length).fill(false);
keep[0] = true;
keep[points.length - 1] = true;
function recurse(start, end) {
let maxDist = 0;
let idx = -1;
for (let i = start + 1; i < end; i++) {
const d = perpendicularDistanceSq(points[i], points[start], points[end]);
if (d > maxDist) { maxDist = d; idx = i; }
}
if (maxDist > t2 && idx !== -1) {
keep[idx] = true;
recurse(start, idx);
recurse(idx, end);
}
}
recurse(0, points.length - 1);
return points.filter((_, i) => keep[i]);
}
tolerance 0.4 px なら見た目は変わらないが点数は 1/3〜1/5 に減る。SVG export のサイズも比例して小さくなる。
可変幅レンダリング
各セグメントの太さを両端の筆圧の平均から決める:
function drawStroke(stroke, ctx) {
const pts = stroke.points;
ctx.strokeStyle = stroke.color;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i];
const wa = pressureToWidth(a.pressure, stroke.baseWidth);
const wb = pressureToWidth(b.pressure, stroke.baseWidth);
ctx.lineWidth = (wa + wb) / 2;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
lineCap: "round" + lineJoin: "round" を 1 度だけ設定しておけば、各セグメントの先端が円形にレンダリングされて、隣接セグメントと滑らかに繋がる。quadraticCurveTo でスムージングしようとすると 可変 lineWidth と組み合わせたときに control point の管理がややこしくなる (実際 1 度書いて円が閉じない不具合を踏んだ)、ので segments + round caps の単純化で十分綺麗。
SVG エクスポートの妥協点
PNG は canvas.toDataURL("image/png") 1 行で出る。SVG はストロークデータから path を組み立てる:
export function strokesToSvg(strokes, width, height, background = null) {
const bg = background === null
? ""
: `<rect x="0" y="0" width="${width}" height="${height}" fill="${escapeAttr(background)}"/>`;
const paths = strokes.map((s) => {
const d = pointsToSvgPath(s.points);
const meanWidth = averageWidth(s);
return `<path d="${d}" fill="none" stroke="${escapeAttr(s.color)}" stroke-width="${fmt(meanWidth)}" stroke-linecap="round" stroke-linejoin="round"/>`;
}).join("");
return `<?xml version="1.0" encoding="UTF-8"?><svg ...>${bg}${paths}</svg>`;
}
ここで 妥協がある: SVG の <path stroke-width> は 1 ストロークに 1 値。Canvas 側でやっていた「セグメントごとに太さを変える」ことができない。完全に再現するなら polygon outline を作る (各点で stroke の輪郭を計算して <path> を fill=true で描く) が、データサイズが 5〜10 倍になるので、ストローク内の平均幅で 1 値に潰す ことにした。
color 属性の escape も忘れずに。color は color-picker からしか入らないが、防御的にエスケープしてテストでも固定:
test("strokesToSvg escapes the color attribute against injection", () => {
const svg = strokesToSvg(
[{ color: '"><script>', baseWidth: 1, points: [pt(0, 0)] }],
10, 10,
);
assert.ok(!svg.includes("<script>"));
assert.ok(svg.includes("<script>"));
});
まとめ
-
MouseEvent/TouchEventは捨てる。PointerEvent1 系統で全部処理できる -
e.pressureは信用しすぎない。0 のときは fallback、out-of-range は clamp -
getCoalescedEvents()を呼ばないと Apple Pencil の 240 Hz が 60 Hz に間引かれる。返り値が空のときは[e]にフォールバック (合成イベントで死なないように) -
可変幅は segments + round caps の素朴版で十分綺麗。
quadraticCurveToと混ぜると path 管理が複雑化 - SVG export は per-stroke 1 太さ。完全再現は polygon outline、要らないなら平均幅で OK
ソース: https://github.com/sen-ltd/canvas-notebook — MIT、合計 ~300 行、18 ユニットテスト、ビルド不要、依存ゼロ。
🛠 本記事は SEN 合同会社 が公開している小さな開発者ツール群の 1 つ。他は portfolio 一覧 から。
