Canvas圖形編輯器-資料結構與History(undo/redo)
這是作為 社群老給我推Canvas,於是我也學習Canvas做了個簡歷編輯器 的後續內容,主要是介紹了對資料結構的設計以及History
能力的實現。
- 線上編輯: https://windrunnermax.github.io/CanvasEditor
- 開源地址: https://github.com/WindrunnerMax/CanvasEditor
關於Canvas
簡歷編輯器專案的相關文章:
- 社群老給我推Canvas,我也學習Canvas做了個簡歷編輯器
- Canvas圖形編輯器-資料結構與History(undo/redo)
- Canvas圖形編輯器-我的剪貼簿裡究竟有什麼資料
- Canvas簡歷編輯器-圖形繪製與狀態管理(輕量級DOM)
- Canvas簡歷編輯器-Monorepo+Rspack工程實踐
描述
對於編輯器而言,History
也就是undo
和redo
是必不可少的能力,實現歷史記錄的方法通常有兩種:
-
儲存全量快照,也就是說我我們每進行一個操作,都需要將全量的資料通常也就是
JSON
格式的資料存到一個陣列裡,如果使用者此時觸發了redo
就將全量的資料取出應用到Editor
物件當中。這種實現方式的優點是簡單,不需要過多的設計,缺點就是一旦操作的多了就容易炸記憶體。 -
基於
Op
的實現,Op
就是對於一個操作的原子化記錄,舉個例子如果將圖形A
向右移動3px
,那麼這個Op
就可以是type: "MOVE", offset: [3, 0]
,那麼如果想要做回退操作依然很簡單,只需要將其反向操作即type: "MOVE", offset: [-3, 0]
就可以了,這種方式的優點是粒度更細,儲存壓力小,缺點是需要複雜的設計以及計算。
既然我們是從零開始設計一個編輯器,那麼大機率是不會採用方案1
的,我們更希望能夠設計原子化的Op
來實現History
,所以從這個方向開始我們就需要先設計資料結構。
資料結構
我特別推薦大家去看一下 quill-delta 的資料結構設計,這個資料結構的設計非常棒,其可以用來描述一篇富文字,同時也可以用來構建change
對富文字做完整的增刪改操作,對於資料的compose
、invert
、diff
等操作也一應俱全,而且quill-delta
也可以是富文字OT
協同演算法的實現,這其中的設計還是非常牛逼的。
其實我之前也沒有設計過資料結構,更不用談設計Op
去實現歷史記錄功能了,所以我在設計資料結構的時候是抓耳撓腮、寢食難安,想設計出 quill-delta
這種級別的資料描述幾乎是不可能了,所以只能依照我的想法來簡單地設計,這其中有很多不完善的地方後邊可能還會有所改動。
因為之前也沒有接觸過Canvas
,所以我的主要目標是學習,所以我希望任何的實現都以儘可能簡單的方向走。那麼在這裡我認為任何元素都是矩形,因為繪製矩陣是比較簡單的,所以圖形元素基類的x, y, width, height
屬性是確定的,再加上還有層級結構,那麼就再加一個z
,此外由於需要標識圖形,所以還需要給其設定一個id
。
class Delta {
public readonly id: string;
protected x: number;
protected y: number;
protected z: number;
protected width: number;
protected height: number;
}
因為我想做一個外掛化的實現,也就是說所有的圖形都應該繼承這個類,那麼這個自定義的函式體肯定是需要儲存自己的資料,所以在這裡加一個attrs
屬性,又因為想簡單實現整個功能,所以這個資料型別就被定義為Record<string, string>
。因為是外掛化的,每個圖形的繪製應該由子類來實現,所以需要定義繪製函式的抽象方法,於是一個資料結構就這麼設計好了,關於外掛化的設計我們後續可以再繼續聊。
abstract class Delta {
public readonly id: string;
protected x: number;
protected y: number;
protected z: number;
protected width: number;
protected height: number;
public attrs: DeltaAttributes;
public abstract drawing: (ctx: CanvasRenderingContext2D) => void;
}
那麼現在已經有了基本的資料結構,我們可以設想一下究竟應該有哪幾種操作,經過考慮大概無非是 插入INSERT
、刪除DELETE
、移動MOVE
、調整大小RESIZE
、修改屬性REVISE
,這五個Op
就可以覆蓋我們對於當前編輯器圖形的所有操作了,所以我們後續的設計都要圍繞著這五個操作來進行。
看起來其實並不難,但實際上想要將其設計好並不容易,因為我們目標是History
所以我們不光要顧及正向的操作,還需要設計好invert
也就是反向操作,依舊以之前的MOVE
操作舉例,我們移動一個元素可以使用MOVE(3, 0)
,反向操作就可以直接生成也就是MOVE(3, 0).invert = MOVE(-3, 0)
,那麼RESIZE
操作呢,尤其是在多選操作時的RESIZE
,我們需要想辦法讓其能夠實現invert
操作,一種方法是記錄每個點的移動距離,但是這樣對於每個Op
儲存的資訊有點過多,我們在構造一個正向的Op
時也需要將相關的資料拉到Op
中,同樣對於REVISE
而言我們需要將屬性的前值和後值都放在Op
中才可以繼續執行。
那麼如何比較好的解決這個問題呢,很明顯如果我們想用輕量的資料來承載內容,那麼先前的資料在不一定會使用的情況下我們是沒必要儲存的,那是不是可以自動提取相關的內容作為invert-op
呢,當然是可以的,我們可以在進行invert
的時候,將未操作前的Delta
一併作為引數傳入就好了,我們可以來驗證一下,我們的函式簽名將會是Op.invert(Delta) = Op'
。
// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2})
// Next DeltaSet
[{id: "xxx", x: x1 + x2, y: y1 + y2, width: w1, height: w1}]
// Invert InsertOp
RESIZE({id: "xxx", x: -x2, y: -y2})
// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2, width: w2, height: h2})
// Next DeltaSet
[{id: "xxx", x: x2, y: y2, width: w2, height: h2}]
// Invert InsertOp
RESIZE({id: "xxx", x: x1, y: y1, width: w1, height: h1})
看起來是沒有問題的,所以我們現在可以設計全量的Op
和Invert
方法了,在這裡因為我最開始是預計要設計組合也就是將幾個圖形組合在一起操作的能力,所以還預留了一個parentId
作為後期開發擴充用,但是暫時是用不上的所以這個欄位暫時可以忽略。下面的Invert
實際上就是case by case
地進行轉換,INSERT -> DELETE
、DELETE -> INSERT
、MOVE -> MOVE
、RESIZE -> RESIZE
、REVISE -> REVISE
。這其中的DeltaSet
可以理解為當前的所有Delta
資料,型別簽名類似於Record<string, Delta>
,是扁平的結構,便於資料查詢。
export type OpPayload = {
[OP_TYPE.INSERT]: { delta: Delta; parentId: string };
[OP_TYPE.DELETE]: { id: string; parentId: string };
[OP_TYPE.MOVE]: { ids: string[]; x: number; y: number };
[OP_TYPE.RESIZE]: { id: string; x: number; y: number; width: number; height: number };
[OP_TYPE.REVISE]: { id: string; attrs: DeltaAttributes };
};
export class Op<T extends OpType> {
public readonly type: T;
public readonly payload: OpPayload[T];
constructor(type: T, payload: OpPayload[T]) {
this.type = type;
this.payload = payload;
}
public invert(prev: DeltaSet) {
switch (this.type) {
case OP_TYPE.INSERT: {
const payload = this.payload as OpPayload[typeof OP_TYPE.INSERT];
const { delta, parentId } = payload;
return new Op(OP_TYPE.DELETE, { id: delta.id, parentId });
}
case OP_TYPE.DELETE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.DELETE];
const { id, parentId } = payload;
const delta = prev.get(id);
if (!delta) return null;
return new Op(OP_TYPE.INSERT, { delta, parentId });
}
case OP_TYPE.MOVE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.MOVE];
const { x, y, ids } = payload;
return new Op(OP_TYPE.MOVE, { ids, x: -x, y: -y });
}
case OP_TYPE.RESIZE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.RESIZE];
const { id } = payload;
const delta = prev.get(id);
if (!delta) return null;
const { x, y, width, height } = delta.getRect();
return new Op(OP_TYPE.RESIZE, { id, x, y, width, height });
}
case OP_TYPE.REVISE: {
const payload = this.payload as OpPayload[typeof OP_TYPE.REVISE];
const { id, attrs } = payload;
const delta = prev.get(id);
if (!delta) return null;
const prevAttrs: DeltaAttributes = {};
for (const key of Object.keys(attrs)) {
prevAttrs[key] = delta.getAttr(key);
}
return new Op(OP_TYPE.REVISE, { id, attrs: prevAttrs });
}
default:
break;
}
return null;
}
}
History
既然我們已經設計好了基於Op
的原子化操作以及資料結構,那麼緊接著我們就可以開始做History
能力了,在這裡首先需要注意我們先前對於Invert
的思想是讓其根據DeltaSet
自動先生成InvertOp
,在這裡我們可以有兩種方案來實現。
-
第一種方式是在應用
Op
之前我們先根據當前的DeltaSet
自動生成一個InvertOp
,然後將這個Op
交給History
模組儲存起來作為Undo
的組操作即可。 -
第二種方式是我們在應用
Op
之前首先生成一遍新的Previous DeltaSet
,是一個immer
的副本,然後將Prev DeltaSet
以及Next DeltaSet
一併作為OnChangeEvent
交給History
模組進行後續的操作。
最終我是選擇了方案二作為整體實現,倒是沒有什麼具體依據,只是覺得這個immer
的副本可能不僅會在這裡使用,作為事件的一部分分發先前的資料值我認為是合理的,所以在應用Op
的時候大致實現如下。
public apply(op: OpSetType, applyOptions?: ApplyOptions) {
const options = applyOptions || { source: "user", undoable: true };
const previous = new DeltaSet(this.editor.deltaSet.getDeltas());
switch (op.type) {
// 根據不同的`Op`執行不同的操作
}
this.editor.event.trigger(EDITOR_EVENT.CONTENT_CHANGE, {
previous,
current: this.editor.deltaSet,
changes: op,
options,
});
}
其實我們也可以看到,整個編輯器內部的通訊是依賴於event
這個模組的,也就是說這個apply
函式不會直接呼叫History
的相關內容,我們的History
模組是獨立掛載CONTENT_CHANGE
事件的。那麼緊接著,我們需要設計History
模組的資料儲存,我們先來明確一下想要實現的內容,現在原子化的Op
已經設計好了,所以在設計History
模組時就不需要全量儲存快照了,但是如果每個操作都需要併入History Stack
的話可能並不是很好,通常都是有N
個Op
的一併Undo/Redo
,所以這個模組應該有一個定時器與快取陣列還有最大時間,如果在N
毫秒秒內沒有新的Op
加入的話就將Op
併入History Stack
,還有就是常規的undo stack
以及redo stack
,棧儲存的內容也不應該很大,所以還需要設定最大儲存量。
export class History {
private readonly DELAY = 800;
private readonly STACK_SIZE = 100;
private temp: OpSetType[];
private undoStack: OpSetType[][];
private redoStack: OpSetType[][];
private timer: ReturnType<typeof setTimeout> | null;
}
前邊也提到過我們都是透過事件來進行通訊的,所以這裡需要先掛載事件,並且在這裡將Invert
的Op
構建好,將其置入批次操作的快取中。
constructor(private editor: Editor) {
this.editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange, 10);
}
destroy() {
this.editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange);
}
private onContentChange = (e: ContentChangeEvent) => {
if (!e.options.undoable) return void 0;
this.redoStack = [];
const { previous, changes } = e;
const invert = changes.invert(previous);
if (invert) {
this.temp.push(invert);
if(!this.timer) {
this.timer = setTimeout(this.collectImmediately, this.DELAY);
}
}
};
後來我在思考一個問題,如果這N
毫秒內使用者進行了Undo
操作應該怎麼辦,後來想想實際上很簡單,此時只需要清除定時器,將暫存的Op[]
立即放置於Redo Stack
即可。
private collectImmediately = () => {
if (!this.temp.length) return void 0;
this.undoStack.push(this.temp);
this.temp = [];
this.redoStack = [];
this.timer && clearTimeout(this.timer);
this.timer = null;
if (this.undoStack.length > this.STACK_SIZE) this.undoStack.shift();
};
後邊就是實際進行redo
和undo
的操作了,只不過在這裡批次操作是使用迴圈每個Op
都需要單獨Apply
的,這樣感覺並不是很好,畢竟需要修改多次,雖然後邊的渲染我只會進行一次批次渲染,但是這裡事件觸發的次數有點多,另外這裡有個點還需要注意,我們在History
模組裡進行的操作,本身不應該再記入History
中,所以這裡還有一個ApplyOptions
的設定需要注意。此外,在undo
之後需要將這部分內容再次invert
之後入redo stack
,反過來也是一樣的,此時我們直接取當前編輯器的DeltaSet
即可。
public undo() {
this.collectImmediately();
if (!this.undoStack.length) return void 0;
const ops = this.undoStack.pop();
if (!ops) return void 0;
this.editor.canvas.mask.clearWithOp();
this.redoStack.push(
ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]
);
this.editor.logger.debug("UNDO", ops);
ops.forEach(op => this.editor.state.apply(op, { source: "undo", undoable: false }));
}
public redo() {
if (!this.redoStack.length) return void 0;
const ops = this.redoStack.pop();
if (!ops) return void 0;
this.editor.canvas.mask.clearWithOp();
this.undoStack.push(
ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]
);
this.editor.logger.debug("REDO", ops);
ops.forEach(op => this.editor.state.apply(op, { source: "redo", undoable: false }));
}
最後
本文我們介紹總結了我們的圖形編輯器中資料結構的設計以及History
模組的實現,雖然暫時不涉及到Canvas
本身,但是這都是作為編輯器本身的基礎能力,也是通用的能力可以學習。後邊我們可以介紹的能力還有很多,例如複製貼上模組、畫布分層、事件管理、無限畫布、按需繪製、效能最佳化、焦點控制、參考線、富文字、快捷鍵、層級控制、渲染順序、事件模擬、PDF
排版等等,整體來說還是比較有意思的,歡迎關注我並留意後續的文章。