圖形編輯器開發(fā):實(shí)現(xiàn)圖形的復(fù)制粘貼
大家好,我是前端西瓜哥。
今天這篇文字來講解一下圖形編輯器如何實(shí)現(xiàn)圖形的復(fù)制粘貼。
粘貼的范圍
首先需要確認(rèn)一下粘貼的范圍。
如果只支持粘貼到當(dāng)前編輯器下,方案很簡單:只需要監(jiān)聽 Ctrl + C
鍵盤事件深拷貝一份選中圖形對(duì)象,然后再監(jiān)聽 Ctrl + V
事件,將拷貝出來的對(duì)象添加到圖形樹的末尾。
但通常我們希望可以跨 tab 頁,跨圖紙,跨瀏覽器,甚至從 Web 端復(fù)制到桌面端。
很明顯,要實(shí)現(xiàn)這樣的場景,我們需要操作系統(tǒng)級(jí)的支持:剪貼板。
我們看看怎么實(shí)現(xiàn)通過剪貼板實(shí)現(xiàn)圖形的復(fù)制粘貼。
復(fù)制邏輯
先是復(fù)制邏輯。
復(fù)制通常為兩種方式:
- 快捷鍵 Ctrl/Cmd + C 。
- 在選中的元素上方右鍵出現(xiàn)菜單選項(xiàng)。選中 “復(fù)制” 選項(xiàng)。
如下圖:
當(dāng)調(diào)用復(fù)制命令時(shí),我們要將 選中的圖形生成序列化快照。
所謂序列化,就是將內(nèi)存中的對(duì)象轉(zhuǎn)換為可以持久化的數(shù)據(jù)。最簡單快捷的就是用 JSON.stringify() 序列化為 JSON 字符串。
除了圖形對(duì)象 data,我們還要保存一些必要的元信息。
最后我們要保存的信息有:
- data:選中圖形的數(shù)組(只有屬性值)。
- appVersion:編輯器版本。隨著編輯器的迭代,圖紙存儲(chǔ)結(jié)構(gòu)可能會(huì)發(fā)生變化,我們需要版本號(hào)來做兼容處理。
- paperId:圖紙 id,用來判斷是否跨圖紙粘貼。跨還是不跨圖紙,粘貼策略有所不同,后面會(huì)說明。
/**
* 生成選中圖形的快照,并保存到操作系統(tǒng)剪貼板中
*/
const getSelectedItemsSnapshot() => {
const selectedItems = selectSet.getItems();
if (selectedItems.length === 0) {
return null;
}
// 提取圖形原始屬性,丟掉多余屬性(比如 id)
const copiedData = arrMap(selectedItems, (item) =>
lodash.omit(item.getAttrs(), 'id'),
);
// 序列化
return JSON.stringify({
appVersion: this.editor.appVersion,
paperId: this.editor.paperId,
data: JSON.stringify(copiedData),
});
}
拿到快照信息后,我們會(huì)調(diào)用 navigator.clipboard.writeText() 方法,將數(shù)據(jù)保存到操作系統(tǒng)的剪貼板中。
/**
* 綁定 Ctrl/Cmd + C 的事件響應(yīng)函數(shù)
*/
const copyHandler = () => {
const snapshot = getSelectedItemsSnapshot();
if (!snapshot) {
return;
}
// 將序列化結(jié)果保存到剪貼板
navigator.clipboard.writeText(snapshot).then(() => {
// 這里可以考慮加一個(gè) “復(fù)制成功” 彈窗提示
console.log('copied');
});
};
hotkeys('cmd+c, ctrl+c', copyHandler);
粘貼
然后就是粘貼了。
粘貼分為右鍵粘貼和快捷鍵粘貼。
右鍵粘貼
這里的右鍵粘貼使用了 clipboard.readText() 方法。因?yàn)樵摲椒ú皇怯脩舻闹鲃?dòng)動(dòng)作,涉及到用戶隱私問題,所以需要用戶授權(quán)剪貼板權(quán)限才行。
另外,F(xiàn)irefox 瀏覽器直接報(bào)錯(cuò),不會(huì)彈出剪貼板授權(quán)彈窗。
這不是個(gè)技術(shù)問題,因?yàn)榭梢允謩?dòng)修改 Firefox 瀏覽器設(shè)置啟用剪貼板授權(quán)。它更是一個(gè)安全問題,F(xiàn)irefox 不認(rèn)為用戶能夠正確地授權(quán)粘貼板操作,以及開發(fā)者不會(huì)濫用這個(gè)權(quán)限收集用戶隱私。
右鍵粘貼因?yàn)樘峁┝斯鈽?biāo)位置,所以我們可以將圖形的位置對(duì)上這個(gè)位置。
快捷鍵粘貼
前面我們因?yàn)橹鲃?dòng)獲取剪貼板的內(nèi)容,所以有權(quán)限問題。
但如果我們監(jiān)聽用戶的 “粘貼” 操作,權(quán)限就寬松了很多,不需要授權(quán)。
因?yàn)檫@是用戶的主動(dòng)行為,用戶從剪貼板取出了數(shù)據(jù)交給你,而不是你主動(dòng)去訪問剪貼板的數(shù)據(jù)。
const pasteHandler = (e: Event) => {
const event = e as ClipboardEvent;
const clipboardData = event.clipboardData;
if (!clipboardData) {
return;
}
// 拿到粘貼的文本內(nèi)容
const pastedData = clipboardData.getData('Text');
// ...
};
// 監(jiān)聽 “粘貼” 事件
window.addEventListener('paste', pasteHandler);
如果用戶拒絕授權(quán),我們可以考慮提示用戶 “用 Ctrl + C 的方式粘貼”,或者用用戶上次右鍵粘貼的內(nèi)容湊數(shù),雖然可能貨不對(duì)版,但好歹有個(gè)東西。
相同圖紙下右鍵粘貼
快捷鍵粘貼沒有光標(biāo)操作,所以粘貼圖形的位置需要用另一種方式去處理。
我們需要考慮兩種情況:相同圖紙和跨圖紙。
對(duì)于在同一個(gè)圖紙下快捷鍵粘貼,圖形復(fù)制時(shí)在哪里,粘貼也在哪里。
或者你可以給一個(gè)小的右下偏移,讓用戶感知到粘貼成功了。我個(gè)人不喜歡這個(gè)偏移,因?yàn)橥ǔN覐?fù)制,就是為了讓圖形做重復(fù)對(duì)齊排列的,我還得給它移動(dòng)回去。
在另一張圖紙下右鍵粘貼
如果是在另一張圖紙下粘貼,我們就不能這么做了。
為什么呢?
舉個(gè)例子,假設(shè)用戶復(fù)制了圖紙 A 中在 (10000, 10000) 坐標(biāo)的圖形。然后我打開圖紙 B,圖紙 B 此時(shí)視口的中心坐標(biāo)在 (0, 0)。
用戶一粘貼,然后說,誒,粘貼的圖形哪去了?你說我可以讓視口移動(dòng)到粘貼圖形的位置,那用戶會(huì)說,誒,我在哪里,我的其他圖形哪去了?
所以 對(duì)于跨圖紙場景,最好的做法是將圖形粘貼到畫布正中心。
代碼實(shí)現(xiàn)
代碼邏輯有點(diǎn)多,就不文字?jǐn)⑹隽耍创a里面的注釋吧。
class ClipboardManager {
private unbindEvents = noop;
constructor(private editor: Editor) {}
bindEvents() {
// Ctrl+C 鍵盤事件響應(yīng)函數(shù)
const copyHandler = () => {
this.copy();
};
// 粘貼事件響應(yīng)函數(shù)
const pasteHandler = (e: Event) => {
const event = e as ClipboardEvent;
const clipboardData = event.clipboardData;
if (!clipboardData) {
return;
}
const pastedData = clipboardData.getData('Text');
this.addGraphsFromClipboard(pastedData);
};
hotkeys('cmd+c, ctrl+c', copyHandler);
window.addEventListener('paste', pasteHandler);
this.unbindEvents = () => {
hotkeys.unbind('cmd+c, ctrl+c', copyHandler);
window.removeEventListener('paste', pasteHandler);
};
}
/**
* 將快照保存到剪貼板
*/
copy() {
const snapshot = this.getSelectedItemsSnapshot();
if (!snapshot) {
return;
}
navigator.clipboard.writeText(snapshot).then(() => {
console.log('copied');
});
}
pasteAt(x: number, y: number) {
navigator.clipboard.readText().then((pastedData) => {
this.addGraphsFromClipboard(pastedData, x, y);
});
}
/**
* 生成選中圖形的快照(序列化)
*/
private getSelectedItemsSnapshot() {
const selectedItems = this.editor.selectedElements.getItems();
if (selectedItems.length === 0) {
return null;
}
const copiedData = arrMap(selectedItems, (item) =>
omit(item.getAttrs(), 'id'),
);
return JSON.stringify({
appVersion: this.editor.appVersion,
paperId: this.editor.paperId,
data: JSON.stringify(copiedData),
});
}
// 在指定坐標(biāo)位置粘貼內(nèi)容
private addGraphsFromClipboard(dataStr: string): void;
private addGraphsFromClipboard(dataStr: string, x: number, y: number): void;
private addGraphsFromClipboard(dataStr: string, x?: number, y?: number) {
let pastedData: IEditorPaperData | null = null;
try {
// 反序列化
pastedData = JSON.parse(dataStr);
} catch (e) {
return;
}
// 數(shù)據(jù)格式校驗(yàn)
if (
!(
pastedData &&
pastedData.appVersion.startsWith('suika-editor') &&
pastedData.data
)
) {
return;
}
const editor = this.editor;
// 將數(shù)據(jù)解析并添加到圖形樹中
const pastedGraphs = editor.sceneGraph.addGraphsByStr(pastedData.data);
if (pastedGraphs.length === 0) {
return;
}
// 添加到歷史記錄(以實(shí)現(xiàn)撤銷重做)
editor.commandManager.pushCommand(
new AddShapeCommand('pasted graphs', editor, pastedGraphs),
);
// 標(biāo)記粘貼圖形為選中狀態(tài)
editor.selectedElements.setItems(pastedGraphs);
const bbox = editor.selectedElements.getBBox()!;
// 如果是右鍵粘貼(x 和 y 沒有值)且跨圖紙粘貼,計(jì)算粘貼圖形要移動(dòng)的目標(biāo)位置
if (
(x === undefined || y === undefined) &&
pastedData.paperId !== editor.paperId
) {
const vwCenter = this.editor.viewportManager.getCenter();
x = vwCenter.x - bbox.width / 2;
y = vwCenter.y - bbox.height / 2;
}
// 遍歷粘貼圖形,根據(jù) x 和 y 進(jìn)行位置修正
if (x !== undefined && y !== undefined) {
const dx = x - bbox.x;
const dy = y - bbox.y;
if (dx || dy) {
Graph.dMove(pastedGraphs, dx, dy);
}
}
// 渲染畫布
editor.sceneGraph.render();
}
// 銷毀時(shí)解綁事件監(jiān)聽
destroy() {
this.unbindEvents();
}
}
一些優(yōu)化點(diǎn)
這里補(bǔ)充一些可以優(yōu)化的點(diǎn)。
前面的實(shí)現(xiàn)其實(shí)有個(gè)用戶體驗(yàn)不好的地方,就是用戶復(fù)制后,在圖形編輯器外粘貼,會(huì)粘貼出一堆意義不明的字符串。
最好是用戶粘貼不出任何東西,這個(gè)有辦法解決。
之前我們用的是 clipboard.writeText() 方法,給數(shù)據(jù)指定的是 text/plain 的 MIME 類型。
實(shí)際上我們可以用另一個(gè)方法 clipboard.write(),該方法可以指定其他的文本相關(guān) MIME 類型,然后將我們真正的數(shù)據(jù)放到到一些不會(huì)被其他軟件解析的角落里。
我們來看看隔壁 Figma 是怎么做的?它將復(fù)制的數(shù)據(jù)設(shè)置為 text/html 類型。
我再看看它的 HTML 都是什么內(nèi)容。
可以看到數(shù)據(jù)主要保存在兩個(gè) span 元素上,它們都沒有文本內(nèi)容,所以在文本編輯器中進(jìn)行標(biāo)準(zhǔn)的粘貼是粘貼不出任何內(nèi)容的。
但這里 Figma 巧妙地用了一個(gè)自定義的 data-metadata 和 data-buffer 去保存真正的數(shù)據(jù)。這個(gè)數(shù)據(jù)看著像是序列化后的類似 base64 格式的內(nèi)容。
這樣就能巧妙地防止其他文本編輯器能夠粘貼出內(nèi)容,自己的編輯器卻會(huì)在解析 html 結(jié)構(gòu)時(shí)特意去讀這個(gè)自定義屬性拿到數(shù)據(jù)。
代碼實(shí)現(xiàn)大概為:
const blob = new Blob(
[
`<meta charset="utf-8">
<span data-suika-meta="${這里是元數(shù)據(jù)}"></span>
<span data-suika-data="${這里是主體數(shù)據(jù)}"><span>`,
],
{ type: 'text/html' },
);
navigator.clipboard
.write([new ClipboardItem({ [blob.type]: blob })])
.then(() => {
console.log('copied');
});
Firefox 目前(2023.08.06)不支持 ClipboardItem,需要 document.execCommand('copy') 的舊方法來兼容。
如果要用 text/html 這種方式,還要做多幾個(gè)工作:
- 序列化結(jié)果要能放到 html 的屬性值中,需要做一個(gè)轉(zhuǎn)義;
- 粘貼讀取 HTML 內(nèi)容時(shí),額外需要一個(gè) HTML 解析器去解析,千萬不要直接用原生的 DOM 去處理它們,會(huì)有完全問題,比如可能會(huì)有 script 腳本。這個(gè)解析器也不只可以解析復(fù)制的圖形內(nèi)容,還可以用作普通的解析 html 對(duì)應(yīng)生成文本圖形對(duì)象。
然后就是粘貼文字、圖形的情況,這時(shí)我們就不能用 clipboard.writeText(),要用 clipboard.write() 了。
結(jié)尾
總結(jié)一下圖形編輯器的圖形復(fù)制粘貼的邏輯。
在復(fù)制時(shí),要將選中圖形進(jìn)行序列化保存到剪貼板。
粘貼的場景就比較多了。粘貼時(shí)需要反序列化解析數(shù)據(jù),并創(chuàng)建對(duì)象添加到圖形樹上。
粘貼要注意權(quán)限問題,快捷鍵粘貼權(quán)限比較寬松,不需要用戶授權(quán);右鍵粘貼則因?yàn)槭情_發(fā)者的主動(dòng)行為,所以需要授權(quán),如果用戶不授權(quán),可以考慮提示用戶用快捷鍵粘貼的方式,或粘貼上一次快捷鍵粘貼的內(nèi)容。
右鍵粘貼時(shí)需要將圖形粘貼到光標(biāo)位置上??旖萱I粘貼時(shí)則需要考慮是否跨圖紙,如果是相同圖紙,原地粘貼即可;如果是另一張圖紙,則粘貼到視口正中心。