LOGO OA教程 ERP教程 模切知識交流 PMS教程 CRM教程 開發(fā)文檔 其他文檔  
 
網(wǎng)站管理員

Canvas簡歷編輯器-圖形繪制與狀態(tài)管理(輕量級DOM)

freeflydom
2024年8月7日 13:54 本文熱度 1253

在線編輯: 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是用來繪制具體圖形的,這里預(yù)計需要實現(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ù),否則會導(dǎo)致編輯器的圖形模糊。

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)了一個非常混亂的狀態(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)改變,而且因為很多方法會比較復(fù)雜,傳遞了多層,導(dǎo)致狀態(tài)管理雖然比之前好了一些可以明確知道狀態(tài)是因為哪里導(dǎo)致變化的,但是實際上依舊不容易維護。

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 編輯過
關(guān)鍵字查詢
相關(guān)文章
正在查詢...
點晴ERP是一款針對中小制造業(yè)的專業(yè)生產(chǎn)管理軟件系統(tǒng),系統(tǒng)成熟度和易用性得到了國內(nèi)大量中小企業(yè)的青睞。
點晴PMS碼頭管理系統(tǒng)主要針對港口碼頭集裝箱與散貨日常運作、調(diào)度、堆場、車隊、財務(wù)費用、相關(guān)報表等業(yè)務(wù)管理,結(jié)合碼頭的業(yè)務(wù)特點,圍繞調(diào)度、堆場作業(yè)而開發(fā)的。集技術(shù)的先進性、管理的有效性于一體,是物流碼頭及其他港口類企業(yè)的高效ERP管理信息系統(tǒng)。
點晴WMS倉儲管理系統(tǒng)提供了貨物產(chǎn)品管理,銷售管理,采購管理,倉儲管理,倉庫管理,保質(zhì)期管理,貨位管理,庫位管理,生產(chǎn)管理,WMS管理系統(tǒng),標簽打印,條形碼,二維碼管理,批號管理軟件。
點晴免費OA是一款軟件和通用服務(wù)都免費,不限功能、不限時間、不限用戶的免費OA協(xié)同辦公管理系統(tǒng)。
Copyright 2010-2025 ClickSun All Rights Reserved

黄频国产免费高清视频,久久不卡精品中文字幕一区,激情五月天AV电影在线观看,欧美国产韩国日本一区二区
亚洲真实迷奷系列在线播放 | 三级网站在在线观看视频 | 午夜打电话福利视频 | 伊香蕉大片视频观看 | 伊人久久综合线亚洲2019 | 亚洲欧美一区二区不卡精品 |