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
排版等等,整體來說還是比較有意思的,歡迎關注我並留意後續的文章。