CSS transform 縮放下 strokeWidth 要除以 scale

核心觀念:有些東西該縮、有些不該縮

無限畫布用 CSS transform 縮放整個圖層 的副作用:外層 transform: scale() 會把所有 children 視覺上一起縮放,包括那些「不該被縮」的 UI 元素。

什麼東西「該縮」:

  • 矩形、橢圓、筆畫的本體(它是內容,畫布縮就要跟著縮)
  • 圖片、文字內容

什麼東西「不該縮」:

  • 選取邊框 — 縮到 10% 時邊框只剩 0.1px,看不見;放到 500% 時邊框變 5px,醜爆
  • resize handle 控制點 — 永遠應該是螢幕上 8×8 的方塊,這樣手才好抓
  • 對齊輔助線 — 1px 就是 1px,不該被畫布縮放影響
  • 游標下的 hover 高亮

這些是「UI affordance」,使用者跟它互動,不是內容的一部分。

為什麼這值得記成筆記

第一次用 CSS transform 做無限畫布,幾乎一定會踩這個洞 — 而且踩到時還很難 debug,因為:

  1. 縮放 100% 時看不出問題 — 開發時都在 100% 測試,沒事
  2. 縮到 30% 才發現選取框消失 — 還以為是選取邏輯壞了
  3. 直覺修法是「把 strokeWidth 從 1 改大成 5」 — 結果放大時更難看
  4. 正確修法反直覺:要除以 scale,越縮越粗、越放越細

這是「框架不告訴你、踩過才會懂」的隱性知識。scale * (1/scale) = 1 數學上很簡單,但要先建立「不該縮的東西也被縮了」這個認知才會想到去抵銷。

視覺對照

假設選取框 strokeWidth = 1

畫布縮放    寫死 1px      寫 1/scale
─────────────────────────────────
 25%       看起來 0.25px    看起來 1px ✓
100%       看起來 1px       看起來 1px ✓
400%       看起來 4px       看起來 1px ✓

「使用者眼睛看到的粗細」永遠維持 1 螢幕像素 — 不論畫布縮到多少。

做法

<rect
  strokeWidth={selected ? 3 / scale : 1 / scale}
/>

換算:你想要它「在螢幕上看起來幾像素」就寫幾,再除以 scale

兩個 scale 相消:

strokeWidth = N / scale
畫面實際粗 = (N / scale) * scale = N

不只 strokeWidth — 整類問題的通解

任何「想保持螢幕固定尺寸」的屬性都用同一招:

想保持寫法
線寬 1 螢幕像素strokeWidth={1 / scale}
字級 14 螢幕像素fontSize={14 / scale}
Handle 8×8 螢幕像素width={8 / scale}height={8 / scale}
間距 4 螢幕像素padding={4 / scale}

更廣義:任何要顯示在世界座標系裡、但尺寸該按螢幕量的東西,公式都是 screenValue / scale

SVG 有專屬解:vector-effect

針對線寬這一個案例,SVG 提供原生屬性:

<rect vector-effect="non-scaling-stroke" strokeWidth={1} />

效果跟 1 / scale 一樣 — 不必算除法。但只解線寬,字級、handle 大小還是要自己算。

為什麼還是值得用 transform 架構

這是 CSS transform 縮放方案唯一明顯的副作用,但跟它帶來的 GPU 合成效能、資料乾淨度比,這幾個 / scale 微不足道。逐物件自己算座標的代價(每次平移/縮放整棵樹 re-render)遠大於記得 UI affordance 要除以 scale。

相關