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,因為:
- 縮放 100% 時看不出問題 — 開發時都在 100% 測試,沒事
- 縮到 30% 才發現選取框消失 — 還以為是選取邏輯壞了
- 直覺修法是「把 strokeWidth 從 1 改大成 5」 — 結果放大時更難看
- 正確修法反直覺:要除以 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。