圖形編輯器開發:自定義光標
大家好,我是前端西瓜哥。
今天來講講如何在圖形編輯器中使用自定義光標,并對光標其進行管理。
編輯器 github 地址:
https://github.com/F-star/suika
線上體驗:
https://blog.fstars.wang/app/suika/
自定義光標的意義是什么?
光標(游標)在圖形界面交互中是非常基礎的一環。
它是一個指針,懸浮在屏幕的最上層。除了可以標記出指針的當前位置,同時也會通過它獨特的樣式,提示用戶此時可以執行怎么的操作。
比如抓手(grab)光標,是一個展開的手掌,表示可以對目標進行拖拽操作。
縮放(xx-resize)光標,是一個有方向的單(雙)箭頭,表示可以往特定方向移動以改變目標大小。
長得像英文字母 I 的文字(text)光標,則提示可以進行文字的操作,細瘦的垂直線是為了更好地點中字符之間的空白區域。
點擊(pointer)光標,一根手指(食指,不是中指)伸出來是要干嘛,是為了試探,看到按鈕就嘗試點一下,表示某個區域是可點擊的。
操作系統有豐富的光標樣式可以選擇,在 Web 網頁中可以通過 cursor 樣式屬性進行設置。
對于一般應用來說,通常是夠用的。但對于一個成熟的圖形編輯器來說,這還遠遠不夠。
我們還需要一些 更具體的光標樣式來向用戶傳遞信息,比如:
- 旋轉光標:表示圖形可旋轉。cursor 屬性中沒有旋轉光標,勉強可用抓手工具做個平替;
- 支持任意度數的縮放光標。cursor 屬性的縮放光標只有 45 度的正數倍數光標,這精度遠遠不夠。
- 鋼筆工具相關光標:鋼筆光標、錨點光標、新增/刪除點光標;
等等。
此外,自定義光標還有一個很重要的作用,就是 實現不同平臺的視覺一致性。
不同操作系統的 UI 風格是不同的,它們的光標是相當不一致的,會給用戶帶來不同的體驗。
(我希望在 Windows 系統看到 MacOS 的光標)
如何支持自定義光標
沒有光標,我們自己造。
好在 cursor 是支持自定義光標的。
具體用法如下。
.suika-cursor-default {
cursor: url(./cursor-icons/suika-cursor-default.png) 5 5, pointer;
}
值依次為:
- url(<url>):自定義光標的圖片資源 url,因為不大且不希望額外作為單獨資源加載,通常會選擇轉換為 base64 格式內嵌;
- x y:使用相對圖片左上角的像素位置作為光標位置;
- <keyword>:如果沒有指定自定義光標圖片,或者加載光標資源失敗,就會使用瀏覽器支持的光標值,比如 pointer。
我們需要繪制好光標圖片,然后導出為 png(背景為透明度),然后定義好 x 和 y,再通過 css 類包裹一下,然后根據需要在 Canvas 上設置對應的 css 樣式即可。
多種旋轉角度的旋轉和縮放光標
有兩種光標比較特殊,它們有特殊的旋轉角度的參數。
它們就是旋轉和縮放光標。
因為 cursor 這個 css 屬性并不支持設置旋轉角度,所以我們只能繪制 0 到 359 之間度數共 360 個不同的旋轉光標圖片。
縮放光標因為其樣式中心對稱的原因,倒是不需要這么多,只要繪制 0 到 179 共 180 個圖片。
然后是 精細度的問題。
你這里可以整一些貓膩,比如偷懶,抽走一些度數,只給偶數的度數,比如 2、4,奇數的度數都丟掉,沒有 1、3 這些度數。設置光標的時候舍入一下,找最接近的度數。
或者你精益求精,你說間隔 1 度未免太大,我們要更精確一點,我們不僅支持整數,我們還要支持 1.5、6.5 這種中間值,我們要用 720 個圖片。
沒問題,都可以上。
批量生成方案
但是呢,我們發現,這些光標其實都來自一個源圖片,只是旋轉了不同的角度,我們手工一個個操作未免太低效了。
這時候我們就可以自己寫或找一些工具,批量對一張源圖形生成旋轉多種角度后的圖片,然后再寫個腳本去自動生成 css 代碼,把這些圖片引入進去。
這是一個方案,figma 是這么做的。
感覺還是有億點麻煩。沒事,我們有另一個方案。
上面做的是打包前生成大量圖片,那我們可不可以在運行時動態生成光標呢?
可以的。圖片有位圖的,也有矢量的啊,我們可以用一種叫做 SVG 的特殊圖片格式,它的內容是文本,一種的 xml 文本。
我們可以將光標 UI 導出為 SVG,然后在最頂層的元素加上 transform 的旋轉變換。
可以寫一個方法,傳入角度和位置信息,動態生成對應的 SVG 字符串,然后轉成 DataURL 給 cursor 應用上。
大概像這樣:
const getRotationIconDataUrl = (degree, x = 0, y = 0) => {
return `url("data:image/svg+xml,
...
<g fill='none' transform='rotate(${degree} ${x} ${y})'
...
") ${x} ${y}, pointer`
}
canvas.style.cursor = getRotationIconDataUrl(114.544);
開源白板工具 tldraw 選擇了這個方案。
你可以給一個精度很高的旋轉度數。
模塊設計
代碼設計上,我們會設計一個 CursorManager 類進行光標的管理。
這個類最重要的作用就是設置光標值。
setCursor 方法接收一個光標值,除了支持傳統的字符串,也支持 { type: 'rotation'; degree: 45 } 這種形式。
它主要做了如下操作:
- 標準化光標值,比如把度數取余到 0~360 內。
- 比較新舊光標值,相同就跳過。
- 清空原來設置的光標樣式。
- 根據光標不同,執行各自的邏輯。
下面是核心代碼實現。
// 支持的光標類型
export type ICursor =
| 'default'
| { type: 'resize'; degree: number }
| { type: 'rotation'; degree: number }
| 'grab'
| ...
}
class CursorManager {
private cursor: ICursor;
setCursor(cursor: ICursor) {
// 1. 標準化光標值,比如把度數取余到 0~360 內
cursor = this.normalizeCursor(cursor);
// 2. 比較新舊光標值,相同就跳過
if (isEqual(cursor, this.cursor)) {
return;
}
this.cursor = cursor;
const clsPrefix = 'suika-cursor-';
// 3. 清空原來設置的光標樣式
canvasElement.classList.forEach((className) => {
if (className.startsWith(clsPrefix) {
canvasElement.classList.remove(className);
}
});
this.editor.canvasElement.style.cursor = '';
// 4. 根據光標不同,執行各自的邏輯
if (this.customClassCursor.has(cursor)) {
// 這個是注冊了 class 的光標
const className = `${clsPrefix}${cursor}`;
this.editor.canvasElement.classList.add(className);
} else if (typeof cursor == 'string') {
// 用瀏覽器自帶的
this.editor.canvasElement.style.cursor = cursor;
} else if (cursor.type === 'resize') {
// 后面都是使用動態 svg 字符串
this.setResizeCursorInCanvas(cursor.degree);
} else if (cursor.type === 'rotation') {
this.setRotationCursorInCanvas(cursor.degree);
}
}
}
繪制在畫布上的光標
光標還有一種比較少用的方案,也說說吧。
就是有些光標是繪制在畫布上的。
一個經典的例子就是 AutoCAD 的十字光標,這個十字的長度是可以設置的,可以相當長。
如果你修改操作系統的光標,那這個十字便會突破天際地顯示到非繪制區域上。
此外,AutoCAD 的光標并不忠實跟隨操作系統光標,比如有時候會吸附于某點不動,并基于它的位置顯示下拉菜單,此時可以用真正的光標去點選。
考慮到性能,建議把光標放到另一個 canvas 上,和圖形放一個 canvas 會讓畫布中沒做任何操作的圖形頻繁重繪。
結尾
總結一下。
關于圖形編輯器的光標,我們有以下方案:
- 使用瀏覽器本身就提供的一些光標值。優點是成本低,缺點是樣式有限,且不同操作系統風格差異大;
- cursor 支持自定義光標,所以我們可以自己設置自己的一套光標去應用。但其中有一些比較特殊的有各種旋轉方向的光標,需要做特別的處理。一種是用工具批量生產光標圖片,一種是利用 svg 在運行時動態生成;
- 最后是在畫布上渲染光標的方案,適合一些有特殊需求的圖形編輯器。這類圖形編輯器的光標往往可以自定義,且可以非常大,或是它們在某些場景下會脫離鼠標的控制,喜歡特立獨行,比如突然吸附到某個吸附點上。缺點是實現比較復雜,你可能需要像管理圖形一樣去管理它。