在線編輯: https://windrunnermax.github.io/CanvasEditor
開源地址: https://github.com/WindrunnerMax/CanvasEditor
關(guān)于Canvas簡歷編輯器項目的相關(guān)文章:
圖形繪制
我們做項目還是需要從需求出發(fā),首先我們需要明確我們要做的是簡歷編輯器,那么簡歷編輯器要求的圖形類型并不需要很多,只需要 矩形、圖片、富文本 圖形即可,那么我們就可以簡單將其抽象一下,我們只需要認為任何元素都是矩形就可以完成這件事了。
因為繪制矩陣是比較簡單的,我們可以直接從數(shù)據(jù)結(jié)構(gòu)來抽象這部分圖形,圖形元素基類的x, y, width, height屬性是確定的,再加上還有層級結(jié)構(gòu),那么就再加一個z,此外由于需要標識圖形,所以還需要給其設(shè)置一個id。
class Delta {
public readonly id: string;
protected x: number;
protected y: number;
protected z: number;
protected width: number;
protected height: number;
}
那么我們的圖形肯定是有很多屬性的,例如矩形是會存在背景、邊框的大小和顏色,富文本也需要屬性來繪制具體的內(nèi)容,所以我們還需要一個對象來存儲內(nèi)容,而且我們是插件化的實現(xiàn),具體的圖形繪制應(yīng)該是由插件本身來實現(xiàn)的,這部分內(nèi)容需要子類來具體實現(xiàn)。
abstract class Delta {
// ...
public attrs: DeltaAttributes;
public abstract drawing: (ctx: CanvasRenderingContext2D) => void;
}
那么繪制的時候,我們考慮分為兩層繪制的方式,內(nèi)層的Canvas是用來繪制具體圖形的,這里預計需要實現(xiàn)增量更新,而外層的Canvas是用來繪制中間狀態(tài)的,例如選中圖形、多選、調(diào)整圖形位置/大小等,在這里是會全量刷新的,并且后邊可能會在這里繪制標尺。
在這里要注意一個很重要的問題,因為我們的Canvas并不是再是矢量圖形,如果我們是在1080P的顯示器上直接將編輯器的width x height設(shè)置到元素上,那是不會出什么問題的,但是如果此時是2K或者是4K的顯示器的話,就會出現(xiàn)模糊的問題,所以我們需要取得devicePixelRatio即物理像素/設(shè)備獨立像素,所以我們可以通過在window上取得這個值來控制Canvas元素的size屬性。
this.canvas.width = width * ratio;
this.canvas.height = height * ratio;
this.canvas.style.width = width + "px";
this.canvas.style.height = height + "px";
此時我們還需要處理resize的問題,我們可以使用resize-observer-polyfill來實現(xiàn)這部分功能,但是需要注意的是我們的width和height必須要是整數(shù),否則會導致編輯器的圖形模糊。
private onResizeBasic = (entries: ResizeObserverEntry[]) => {
// COMPAT: `onResize`會觸發(fā)首次`render`
const [entry] = entries;
if (!entry) return void 0;
// 置宏任務(wù)隊列
setTimeout(() => {
const { width, height } = entry.contentRect;
this.width = width;
this.height = height;
this.reset();
this.editor.event.trigger(EDITOR_EVENT.RESIZE, { width, height });
}, 0);
};
實際上我們在實現(xiàn)完整的圖形編輯器的時候,可能并不是完整的矩形節(jié)點,例如繪制云形狀的不規(guī)則圖形,我們需要將相關(guān)節(jié)點坐標放置于attrs中,并且在實際繪制的過程中完成Bezier曲線的計算即可。但是實際上我們還需要注意到一個問題,當我們點擊的時候如何判斷這個點是在圖形內(nèi)還是圖形外,如果是圖形內(nèi)則點擊時需要選中節(jié)點,如果在圖形外不會選中節(jié)點,那么因為我們是閉合圖形,所以我們可以用射線法實現(xiàn)這個能力,我們將點向一個方向做射線,如果穿越的節(jié)點數(shù)量是奇數(shù),說明點在內(nèi)部圖形,如果穿越的節(jié)點數(shù)量是偶數(shù),則說明點在圖形外部。
我們僅僅實現(xiàn)圖形的繪制肯定是不行的,我們還需要實現(xiàn)圖形的相關(guān)交互能力。在實現(xiàn)交互的過程中我遇到了一個比較棘手的問題,因為不存在DOM,所有的操作都是需要根據(jù)位置信息來計算的,比如選中圖形后調(diào)整大小的點就需要在選中狀態(tài)下并且點擊的位置恰好是那幾個點外加一定的偏移量,然后再根據(jù)MouseMove事件來調(diào)整圖形大小,而實際上在這里的交互會非常多,包括多選、拖拽框選、Hover效果,都是根據(jù)MouseDown、MouseMove、MouseUp三個事件完成的,所以如何管理狀態(tài)以及繪制UI交互就是個比較麻煩的問題,在這里我只能想到根據(jù)不同的狀態(tài)來攜帶不同的Payload,進而繪制交互。
export enum CANVAS_OP {
HOVER,
RESIZE,
TRANSLATE,
FRAME_SELECT,
}
export enum CANVAS_STATE {
OP = 10,
HOVER = 11,
RESIZE = 12,
LANDING_POINT = 13,
OP_RECT = 14,
}
export type SelectionState = {
[CANVAS_STATE.OP]?:
| CANVAS_OP.HOVER
| CANVAS_OP.RESIZE
| CANVAS_OP.TRANSLATE
| CANVAS_OP.FRAME_SELECT
| null;
[CANVAS_STATE.HOVER]?: string | null;
[CANVAS_STATE.RESIZE]?: RESIZE_TYPE | null;
[CANVAS_STATE.LANDING_POINT]?: Point | null;
[CANVAS_STATE.OP_RECT]?: Range | null;
};
狀態(tài)管理
在實現(xiàn)交互的時候,我思考了很久應(yīng)該如何比較好的實現(xiàn)這個能力,因為上邊也說了這里是沒有DOM的,所以最開始的時候我通過MouseDown、MouseMove、MouseUp實現(xiàn)了一個非?;靵y的狀態(tài)管理,完全是基于事件的觸發(fā)然后執(zhí)行相關(guān)副作用從而調(diào)用Mask Canvas圖層的方法進行重新繪制。
const point = this.editor.canvas.getState(CANVAS_STATE.LANDING_POINT);
const opType = this.editor.canvas.getState(CANVAS_STATE.OP);
// ...
this.editor.canvas.setState(CANVAS_STATE.HOVER, delta.id);
this.editor.canvas.setState(CANVAS_STATE.RESIZE, state);
this.editor.canvas.setState(CANVAS_STATE.OP, CANVAS_OP.RESIZE);
this.editor.canvas.setState(CANVAS_STATE.OP, CANVAS_OP.TRANSLATE);
this.editor.canvas.setState(CANVAS_STATE.OP, CANVAS_OP.FRAME_SELECT);
// ...
this.editor.canvas.setState(CANVAS_STATE.LANDING_POINT, new Point(e.offsetX, e.offsetY));
this.editor.canvas.setState(CANVAS_STATE.LANDING_POINT, null);
this.editor.canvas.setState(CANVAS_STATE.OP_RECT, null);
this.editor.canvas.setState(CANVAS_STATE.OP, null);
// ...
再后來我覺得這樣的代碼根本沒有辦法維護,所以改動了一下,將我所需要的狀態(tài)全部都存儲到一個Store中,通過我自定義的事件管理來通知狀態(tài)的改變,最終通過狀態(tài)改變的類型來嚴格控制將要繪制的內(nèi)容,也算是將相關(guān)的邏輯抽象了一層,只不過在這里相當于是我維護了大量的狀態(tài),而且這些狀態(tài)是相互關(guān)聯(lián)的,所以會有很多的if/else去處理不同類型的狀態(tài)改變,而且因為很多方法會比較復雜,傳遞了多層,導致狀態(tài)管理雖然比之前好了一些可以明確知道狀態(tài)是因為哪里導致變化的,但是實際上依舊不容易維護。
export const CANVAS_STATE = {
OP: "OP",
RECT: "RECT",
HOVER: "HOVER",
RESIZE: "RESIZE",
LANDING: "LANDING",
} as const;
export type CanvasOp = keyof typeof CANVAS_OP;
export type ResizeType = keyof typeof RESIZE_TYPE;
export type CanvasStore = {
[RESIZE_TYPE.L]?: Range | null;
[RESIZE_TYPE.R]?: Range | null;
[RESIZE_TYPE.T]?: Range | null;
[RESIZE_TYPE.B]?: Range | null;
[RESIZE_TYPE.LT]?: Range | null;
[RESIZE_TYPE.RT]?: Range | null;
[RESIZE_TYPE.LB]?: Range | null;
[RESIZE_TYPE.RB]?: Range | null;
[CANVAS_STATE.RECT]?: Range | null;
[CANVAS_STATE.OP]?: CanvasOp | null;
[CANVAS_STATE.HOVER]?: string | null;
[CANVAS_STATE.LANDING]?: Point | null;
[CANVAS_STATE.RESIZE]?: ResizeType | null;
};
最終我又思考了一下,我們在瀏覽器中進行DOM操作的時候,這個DOM是真正存在的嗎,或者說我們在PC上實現(xiàn)窗口管理的時候,這個窗口是真的存在的嗎,答案肯定是否定的,雖然我們可以通過系統(tǒng)或者瀏覽器提供的API來非常簡單地實現(xiàn)各種操作,但是實際上些內(nèi)容是系統(tǒng)幫我們繪制出來的,本質(zhì)上還是圖形,事件、狀態(tài)、碰撞檢測等等都是系統(tǒng)模擬出來的,而我們的Canvas也擁有類似的圖形編程能力。
那么我們當然可以在這里實現(xiàn)類似于DOM的能力,因為我想實現(xiàn)的能力似乎本質(zhì)上就是DOM與事件的關(guān)聯(lián),而DOM結(jié)構(gòu)是一種非常成熟的設(shè)計了,這其中有一些很棒的能力設(shè)計,例如DOM的事件流,我們就不需要扁平化地調(diào)整每個Node的事件,而是只需要保證事件是從ROOT節(jié)點起始,最終又在ROOT上結(jié)束即可。并且整個樹形結(jié)構(gòu)以及狀態(tài)是靠用戶利用DOM的API來實現(xiàn)的,我們管理只需要處理ROOT就好了,這樣就會很方便,下個階段的狀態(tài)管理是準備用這種方式來實現(xiàn)的,那么我們就先實現(xiàn)Node基類。
class Node {
private _range: Range;
private _parent: Node | null;
public readonly children: Node[];
// 盡可能簡單地實現(xiàn)事件流
// 直接通過`bubble`來決定捕獲/冒泡
protected onMouseDown?: (event: MouseEvent) => void;
protected onMouseUp?: (event: MouseEvent) => void;
protected onMouseEnter?: (event: MouseEvent) => void;
protected onMouseLeave?: (event: MouseEvent) => void;
// `Canvas`繪制節(jié)點
public drawingMask?: (ctx: CanvasRenderingContext2D) => void;
constructor(range: Range) {
this.children = [];
this._range = range;
this._parent = null;
}
// ====== Parent ======
public get parent() {
return this._parent;
}
public setParent(parent: Node | null) {
this._parent = parent;
}
// ====== Range ======
public get range() {
return this._range;
}
public setRange(range: Range) {
this._range = range;
}
// ====== DOM OP ======
public append<T extends Node>(node: T | Empty) {
// ...
}
public removeChild<T extends Node>(node: T | Empty) {
// ...
}
public remove() {
// ...
}
public clearNodes() {
// ...
}
}
那么接下來我們只需要定義好類似于HTML的Body元素,在這里我們將其設(shè)置為Root節(jié)點,該元素繼承了Node節(jié)點。在這里我們接管了整個編輯器的事件分發(fā),繼承于此的事件都可以分發(fā)到子節(jié)點,例如我們的點選事件,就可以在子節(jié)點上設(shè)置MouseDown事件處理即可。并且在這里我們還需要設(shè)計事件分發(fā)的能力,我們同樣可以實現(xiàn)事件的捕獲和冒泡機制,通過??梢院芊奖愕膶⑹录挠|發(fā)處理出來。
export class Root extends Node {
constructor(private editor: Editor, private engine: Canvas) {
super(Range.from(0, 0));
}
public getFlatNode(isEventCall = true): Node[] {
// 非默認狀態(tài)下不需要匹配
if (!this.engine.isDefaultMode()) return [];
// 事件調(diào)用實際順序 // 渲染順序則相反
const flatNodes: Node[] = [...super.getFlatNode(), this];
return isEventCall ? flatNodes.filter(node => !node.ignoreEvent) : flatNodes;
}
public onMouseDown = (e: MouseEvent) => {
this.editor.canvas.mask.setCursorState(null);
!e.shiftKey && this.editor.selection.clearActiveDeltas();
};
private emit<T extends keyof NodeEvent>(target: Node, type: T, event: NodeEvent[T]) {
const stack: Node[] = [];
let node: Node | null = target.parent;
while (node) {
stack.push(node);
node = node.parent;
}
// 捕獲階段執(zhí)行的事件
for (const node of stack.reverse()) {
if (!event.capture) break;
const eventFn = node[type as keyof NodeEvent];
eventFn && eventFn(event);
}
// 節(jié)點本身 執(zhí)行即可
const eventFn = target[type as keyof NodeEvent];
eventFn && eventFn(event);
// 冒泡階段執(zhí)行的事件
for (const node of stack) {
if (!event.bubble) break;
const eventFn = node[type as keyof NodeEvent];
eventFn && eventFn(event);
}
}
private onMouseDownController = (e: globalThis.MouseEvent) => {
this.cursor = Point.from(e, this.editor);
// 非默認狀態(tài)下不執(zhí)行事件
if (!this.engine.isDefaultMode()) return void 0;
// 按事件順序獲取節(jié)點
const flatNode = this.getFlatNode();
let hit: Node | null = null;
const point = Point.from(e, this.editor);
for (const node of flatNode) {
if (node.range.include(point)) {
hit = node;
break;
}
}
hit && this.emit(hit, NODE_EVENT.MOUSE_DOWN, MouseEvent.from(e, this.editor));
};
private onMouseMoveBasic = (e: globalThis.MouseEvent) => {
this.cursor = Point.from(e, this.editor);
// 非默認狀態(tài)下不執(zhí)行事件
if (!this.engine.isDefaultMode()) return void 0;
// 按事件順序獲取節(jié)點
const flatNode = this.getFlatNode();
let next: ElementNode | ResizeNode | null = null;
const point = Point.from(e, this.editor);
for (const node of flatNode) {
// 當前只有`ElementNode`和`ResizeNode`需要觸發(fā)`Mouse Enter/Leave`事件
const authorize = node instanceof ElementNode || node instanceof ResizeNode;
if (authorize && node.range.include(point)) {
next = node;
break;
}
}
};
private onMouseMoveController = throttle(this.onMouseMoveBasic, ...THE_CONFIG);
private onMouseUpController = (e: globalThis.MouseEvent) => {
// 非默認狀態(tài)下不執(zhí)行事件
if (!this.engine.isDefaultMode()) return void 0;
// 按事件順序獲取節(jié)點
const flatNode = this.getFlatNode();
let hit: Node | null = null;
const point = Point.from(e, this.editor);
for (const node of flatNode) {
if (node.range.include(point)) {
hit = node;
break;
}
}
hit && this.emit(hit, NODE_EVENT.MOUSE_UP, MouseEvent.from(e, this.editor));
};
}
那么接下來,我們只需要定義相關(guān)節(jié)點類型就可以了,并且通過區(qū)分不同類型就可以來實現(xiàn)不同的功能,例如圖形繪制使用ElementNode節(jié)點,調(diào)整節(jié)點大小使用ResizeNode節(jié)點,框選內(nèi)容使用FrameNode節(jié)點即可,那么在這里我們就先看一下ElementNode節(jié)點,用來表示實際節(jié)點。
class ElementNode extends Node {
private readonly id: string;
private isHovering: boolean;
constructor(private editor: Editor, state: DeltaState) {
const range = state.toRange();
super(range);
this.id = state.id;
const delta = state.toDelta();
const rect = delta.getRect();
this.setZ(rect.z);
this.isHovering = false;
}
protected onMouseDown = (e: MouseEvent) => {
if (e.shiftKey) {
this.editor.selection.addActiveDelta(this.id);
} else {
this.editor.selection.setActiveDelta(this.id);
}
};
protected onMouseEnter = () => {
this.isHovering = true;
if (this.editor.selection.has(this.id)) {
return void 0;
}
this.editor.canvas.mask.drawingEffect(this.range);
};
protected onMouseLeave = () => {
this.isHovering = false;
if (!this.editor.selection.has(this.id)) {
this.editor.canvas.mask.drawingEffect(this.range);
}
};
public drawingMask = (ctx: CanvasRenderingContext2D) => {
if (
this.isHovering &&
!this.editor.selection.has(this.id) &&
!this.editor.state.get(EDITOR_STATE.MOUSE_DOWN)
) {
const { x, y, width, height } = this.range.rect();
Shape.rect(ctx, {
x: x,
y: y,
width: width,
height: height,
borderColor: BLUE_3,
borderWidth: 1,
});
}
};
}
轉(zhuǎn)自https://www.cnblogs.com/WindrunnerMax/p/18346501
該文章在 2024/8/8 8:41:46 編輯過