最近在開發 圖片標注工具 ,用 React + Canvas 做的,需要在手機上支援「單指拖曳文字標注」和「滑動捲動網頁」兩種操作。在 Chrome DevTools 的手機模擬器上測試一切正常,結果部署到線上,拿真正的 iPhone 打開——點擊沒反應、拖曳沒反應、什麼都沒反應。

這篇文章把整個除錯過程踩過的每一個坑都記錄下來,希望能幫到同樣在 iOS Safari 上跟 Canvas 觸控事件搏鬥的開發者們。

需求場景

我們的圖片文字標注工具在 Canvas 上需要支援以下操作:

  • 點擊空白處:新增文字標注
  • 單指拖曳文字:移動標注位置,且不能觸發頁面捲動
  • 手指在圖片空白處滑動:正常捲動網頁

在桌面版 Chrome 和 Chrome DevTools 手機模擬模式下全部正常運作。但 iPhone 上的 Safari 和 Chrome(iOS 版 Chrome 底層也是 WebKit)完全無法操作。

坑 1:touch-action: none 在 iOS Safari 上不可靠

第一個直覺是用 CSS touch-action: none 告訴瀏覽器「這個 Canvas 的觸控我自己處理」。在 Chrome 上這招完美運作,但 iOS Safari 的行為很不一致:

  • iOS 12 以下根本不支援 touch-action: none,只認得 automanipulation
  • iOS 13+ 號稱支援,但實測仍會出現瀏覽器搶走觸控事件的情況
  • 斜向拖曳時 Safari 仍可能判定為頁面捲動

結論:不能單靠 touch-action: none,它只能當作防禦性的輔助手段,必須搭配 JavaScript 的 preventDefault() 才可靠。

坑 2:React 合成事件是 passive 的

React 17 以後,onTouchStartonTouchMove 等合成事件預設是 passive 的。這代表在 React 的事件處理器中呼叫 e.preventDefault() 完全無效——瀏覽器會直接忽略,繼續執行預設行為(捲動頁面)。

// ❌ 這在 iOS Safari 上無效,React 合成事件是 passive 的
<canvas
  onTouchStart={(e) => e.preventDefault()}
  onTouchMove={(e) => e.preventDefault()}
/>

解法是改用原生 addEventListener 並明確指定 { passive: false }

// ✅ 原生事件監聽器,passive: false 才能成功 preventDefault
useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;

  const onTouchMove = (e: TouchEvent) => {
    if (shouldPreventScroll) e.preventDefault();
  };

  canvas.addEventListener("touchmove", onTouchMove, { passive: false });
  return () => canvas.removeEventListener("touchmove", onTouchMove);
}, []);

坑 3:iOS Safari 的 pointercancel 會吃掉事件

在 iOS Safari 上使用 Pointer Events 時,如果沒有在 pointerdown 呼叫 e.preventDefault(),Safari 會在偵測到手指開始移動的瞬間發出 pointercancel,直接搶走觸控事件去做頁面捲動。之後的 pointermovepointerup 都不會再觸發了。

但如果無條件在 pointerdown 呼叫 preventDefault(),整個頁面在 Canvas 區域就完全無法捲動。

解法是只在命中互動元素時才 preventDefault

const onPointerDown = (e: PointerEvent) => {
  if (!e.isPrimary) return;

  const hit = getAnnotationAtPoint(e.clientX, e.clientY);
  if (hit) {
    // 命中文字標注:攔截觸控,開始拖曳
    e.preventDefault();
    canvas.setPointerCapture(e.pointerId);
    startDrag(hit);
  }
  // 沒命中:什麼都不做,讓瀏覽器正常捲動
};

這裡的 setPointerCapture 也很重要——它確保後續的 pointermovepointerup 都會持續送到 Canvas,即使手指移出了元素範圍也不會丟失事件。

坑 4:Canvas 的 onClick 在 iOS 不觸發

iOS Safari 有一套「可點擊性判斷」機制(clickability heuristic)——它不會對「看起來不像按鈕」的元素觸發 click 事件。Canvas 元素預設不被認為是可點擊的,所以綁在上面的 onClick 根本不會被觸發。

解法意外地簡單,加上 cursor: pointer CSS 就行了:

<canvas
  style={{
    cursor: "pointer",           // iOS Safari 需要這個才會觸發 click
    WebkitTouchCallout: "none",  // 防止長按跳出選單
    WebkitUserSelect: "none",    // 防止文字被選取
    userSelect: "none",
  }}
/>

坑 5:crypto.randomUUID() 和 structuredClone() 的相容性

這個不是觸控事件的問題,但同樣會在 iOS 上造成「什麼都不動」的現象。crypto.randomUUID()structuredClone() 都是 iOS 15.4 以後才支援的 API。如果使用者的 iPhone 系統版本較舊,呼叫這些 API 會直接拋出 ReferenceError,導致整個事件處理器靜默失敗——畫面上不會有任何錯誤提示,就是什麼都不會發生。

// ❌ iOS 15.4 以下不支援,整個 handler 會靜默失敗
const id = crypto.randomUUID();
const clone = structuredClone(data);

// ✅ 加上 fallback
function generateId(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return crypto.randomUUID();
  }
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
}

// structuredClone 改用 JSON 方式深拷貝
const clone = JSON.parse(JSON.stringify(data));

最終解法:條件式攔截 + 原生事件

經過多輪嘗試,最終能穩定運作的架構長這樣:

  1. 用原生 Pointer EventsaddEventListener)處理所有互動,不用 React 合成事件
  2. pointerdown 做碰撞偵測:命中標注才 preventDefault + setPointerCapture,沒命中就放行讓瀏覽器捲動
  3. 原生 touchmove 搭配 { passive: false }:用一個 ref 追蹤「目前是否正在拖曳標注」,只在拖曳中才 preventDefault
  4. pointerup 判斷是點擊還是拖曳:累計移動距離沒有超過閾值(5px)就當作點擊,觸發新增文字或選取
  5. 另外提供 UI 按鈕作為 fallback:在屬性面板放一個「新增文字到圖片」按鈕,不依賴 Canvas 觸控事件

核心程式碼大致如下:

useEffect(() => {
  const canvas = canvasRef.current;
  if (!canvas) return;

  // 用 ref 讓原生 touchmove 知道目前是否在拖曳
  const isDraggingRef = useRef(false);

  const onPointerDown = (e: PointerEvent) => {
    if (!e.isPrimary) return;
    const hit = getAnnotationAtPoint(e.clientX, e.clientY);
    if (hit) {
      e.preventDefault();
      canvas.setPointerCapture(e.pointerId);
      isDraggingRef.current = true;
      // ... 開始拖曳邏輯
    }
  };

  const onPointerUp = (e: PointerEvent) => {
    isDraggingRef.current = false;
    // ... 結束拖曳或處理點擊
  };

  // 只在拖曳標注時阻止頁面捲動
  const onTouchMove = (e: TouchEvent) => {
    if (isDraggingRef.current) e.preventDefault();
  };

  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerup", onPointerUp);
  canvas.addEventListener("pointercancel", onPointerCancel);
  canvas.addEventListener("touchmove", onTouchMove, { passive: false });

  return () => { /* cleanup all listeners */ };
}, [imageSrc]);

這個方案的關鍵在於條件式攔截——只有在確定使用者正在拖曳文字標注時才阻止瀏覽器的預設行為,其他所有觸控操作都完全交給瀏覽器處理。

Chrome DevTools 模擬 vs 真機差異

這次踩坑最大的教訓是:Chrome DevTools 的手機模擬不等於真機測試。以下是模擬器與 iOS Safari 實際行為的對照:

行為Chrome DevTools 模擬iOS Safari 真機
touch-action: none完全有效不可靠,仍可能捲動
React onTouchStart 的 preventDefault有效無效(passive 限制)
Canvas 上的 onClick正常觸發需要 cursor: pointer 才會觸發
Pointer Events 不 preventDefault事件正常繼續觸發 pointercancel,後續事件全部消失
crypto.randomUUID()支援iOS 15.4 以上才支援

結論與建議

如果正在開發需要觸控互動的 Canvas 應用,以下是從這次經驗中整理出來的建議:

  1. 不要用 React 合成事件處理觸控:改用原生 addEventListener + { passive: false },這是唯一能在 iOS Safari 上可靠呼叫 preventDefault() 的方式
  2. 不要無條件 preventDefault:只在需要的時候(例如拖曳互動元素)才攔截,否則頁面捲動會壞掉
  3. 優先使用 Pointer Events:統一的 API 同時處理滑鼠和觸控,搭配 setPointerCapture 確保事件序列不會中斷
  4. Canvas 記得加 cursor: pointer:否則 iOS Safari 不認為它是可互動元素,click 事件不會觸發
  5. 提供 UI 按鈕作為 fallback:不要把所有操作都綁在 Canvas 觸控上,HTML 按鈕在任何平台都能可靠運作
  6. 注意 API 相容性crypto.randomUUID()structuredClone() 這些較新的 API 在舊版 iOS 上會直接爆炸,記得加 fallback
  7. 一定要用真機測試:Chrome DevTools 模擬跟 iOS Safari 實際行為的差異非常大,模擬器通過不代表真機能用

發佈留言