狠狠色丁香婷婷综合尤物/久久精品综合一区二区三区/中国有色金属学报/国产日韩欧美在线观看 - 国产一区二区三区四区五区tv

LOGO OA教程 ERP教程 模切知識交流 PMS教程 CRM教程 開發文檔 其他文檔  
 
網站管理員

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

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

在線編輯: https://windrunnermax.github.io/CanvasEditor

開源地址: https://github.com/WindrunnerMax/CanvasEditor

關于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;

}


那么我們的圖形肯定是有很多屬性的,例如矩形是會存在背景、邊框的大小和顏色,富文本也需要屬性來繪制具體的內容,所以我們還需要一個對象來存儲內容,而且我們是插件化的實現,具體的圖形繪制應該是由插件本身來實現的,這部分內容需要子類來具體實現。

abstract class Delta {

  // ...

  public attrs: DeltaAttributes;

  public abstract drawing: (ctx: CanvasRenderingContext2D) => void;

}


那么繪制的時候,我們考慮分為兩層繪制的方式,內層的Canvas是用來繪制具體圖形的,這里預計需要實現增量更新,而外層的Canvas是用來繪制中間狀態的,例如選中圖形、多選、調整圖形位置/大小等,在這里是會全量刷新的,并且后邊可能會在這里繪制標尺。

 

在這里要注意一個很重要的問題,因為我們的Canvas并不是再是矢量圖形,如果我們是在1080P的顯示器上直接將編輯器的width x height設置到元素上,那是不會出什么問題的,但是如果此時是2K或者是4K的顯示器的話,就會出現模糊的問題,所以我們需要取得devicePixelRatio即物理像素/設備獨立像素,所以我們可以通過在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來實現這部分功能,但是需要注意的是我們的width和height必須要是整數,否則會導致編輯器的圖形模糊。

private onResizeBasic = (entries: ResizeObserverEntry[]) => {

  // COMPAT: `onResize`會觸發首次`render`

  const [entry] = entries;

  if (!entry) return void 0;

  // 置宏任務隊列

  setTimeout(() => {

    const { width, height } = entry.contentRect;

    this.width = width;

    this.height = height;

    this.reset();

    this.editor.event.trigger(EDITOR_EVENT.RESIZE, { width, height });

  }, 0);

};


實際上我們在實現完整的圖形編輯器的時候,可能并不是完整的矩形節點,例如繪制云形狀的不規則圖形,我們需要將相關節點坐標放置于attrs中,并且在實際繪制的過程中完成Bezier曲線的計算即可。但是實際上我們還需要注意到一個問題,當我們點擊的時候如何判斷這個點是在圖形內還是圖形外,如果是圖形內則點擊時需要選中節點,如果在圖形外不會選中節點,那么因為我們是閉合圖形,所以我們可以用射線法實現這個能力,我們將點向一個方向做射線,如果穿越的節點數量是奇數,說明點在內部圖形,如果穿越的節點數量是偶數,則說明點在圖形外部。

我們僅僅實現圖形的繪制肯定是不行的,我們還需要實現圖形的相關交互能力。在實現交互的過程中我遇到了一個比較棘手的問題,因為不存在DOM,所有的操作都是需要根據位置信息來計算的,比如選中圖形后調整大小的點就需要在選中狀態下并且點擊的位置恰好是那幾個點外加一定的偏移量,然后再根據MouseMove事件來調整圖形大小,而實際上在這里的交互會非常多,包括多選、拖拽框選、Hover效果,都是根據MouseDown、MouseMove、MouseUp三個事件完成的,所以如何管理狀態以及繪制UI交互就是個比較麻煩的問題,在這里我只能想到根據不同的狀態來攜帶不同的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;

};


狀態管理

在實現交互的時候,我思考了很久應該如何比較好的實現這個能力,因為上邊也說了這里是沒有DOM的,所以最開始的時候我通過MouseDown、MouseMove、MouseUp實現了一個非常混亂的狀態管理,完全是基于事件的觸發然后執行相關副作用從而調用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);

// ...


再后來我覺得這樣的代碼根本沒有辦法維護,所以改動了一下,將我所需要的狀態全部都存儲到一個Store中,通過我自定義的事件管理來通知狀態的改變,最終通過狀態改變的類型來嚴格控制將要繪制的內容,也算是將相關的邏輯抽象了一層,只不過在這里相當于是我維護了大量的狀態,而且這些狀態是相互關聯的,所以會有很多的if/else去處理不同類型的狀態改變,而且因為很多方法會比較復雜,傳遞了多層,導致狀態管理雖然比之前好了一些可以明確知道狀態是因為哪里導致變化的,但是實際上依舊不容易維護。

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上實現窗口管理的時候,這個窗口是真的存在的嗎,答案肯定是否定的,雖然我們可以通過系統或者瀏覽器提供的API來非常簡單地實現各種操作,但是實際上些內容是系統幫我們繪制出來的,本質上還是圖形,事件、狀態、碰撞檢測等等都是系統模擬出來的,而我們的Canvas也擁有類似的圖形編程能力。

那么我們當然可以在這里實現類似于DOM的能力,因為我想實現的能力似乎本質上就是DOM與事件的關聯,而DOM結構是一種非常成熟的設計了,這其中有一些很棒的能力設計,例如DOM的事件流,我們就不需要扁平化地調整每個Node的事件,而是只需要保證事件是從ROOT節點起始,最終又在ROOT上結束即可。并且整個樹形結構以及狀態是靠用戶利用DOM的API來實現的,我們管理只需要處理ROOT就好了,這樣就會很方便,下個階段的狀態管理是準備用這種方式來實現的,那么我們就先實現Node基類。

class Node {

  private _range: Range;

  private _parent: Node | null;

  public readonly children: Node[];


  // 盡可能簡單地實現事件流

  // 直接通過`bubble`來決定捕獲/冒泡

  protected onMouseDown?: (event: MouseEvent) => void;

  protected onMouseUp?: (event: MouseEvent) => void;

  protected onMouseEnter?: (event: MouseEvent) => void;

  protected onMouseLeave?: (event: MouseEvent) => void;


  // `Canvas`繪制節點

  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元素,在這里我們將其設置為Root節點,該元素繼承了Node節點。在這里我們接管了整個編輯器的事件分發,繼承于此的事件都可以分發到子節點,例如我們的點選事件,就可以在子節點上設置MouseDown事件處理即可。并且在這里我們還需要設計事件分發的能力,我們同樣可以實現事件的捕獲和冒泡機制,通過棧可以很方便的將事件的觸發處理出來。

export class Root extends Node {

  constructor(private editor: Editor, private engine: Canvas) {

    super(Range.from(0, 0));

  }


  public getFlatNode(isEventCall = true): Node[] {

    // 非默認狀態下不需要匹配

    if (!this.engine.isDefaultMode()) return [];

    // 事件調用實際順序 // 渲染順序則相反

    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;

    }

    // 捕獲階段執行的事件

    for (const node of stack.reverse()) {

      if (!event.capture) break;

      const eventFn = node[type as keyof NodeEvent];

      eventFn && eventFn(event);

    }

    // 節點本身 執行即可

    const eventFn = target[type as keyof NodeEvent];

    eventFn && eventFn(event);

    // 冒泡階段執行的事件

    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);

    // 非默認狀態下不執行事件

    if (!this.engine.isDefaultMode()) return void 0;

    // 按事件順序獲取節點

    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);

    // 非默認狀態下不執行事件

    if (!this.engine.isDefaultMode()) return void 0;

    // 按事件順序獲取節點

    const flatNode = this.getFlatNode();

    let next: ElementNode | ResizeNode | null = null;

    const point = Point.from(e, this.editor);

    for (const node of flatNode) {

      // 當前只有`ElementNode`和`ResizeNode`需要觸發`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) => {

    // 非默認狀態下不執行事件

    if (!this.engine.isDefaultMode()) return void 0;

    // 按事件順序獲取節點

    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));

  };

}


那么接下來,我們只需要定義相關節點類型就可以了,并且通過區分不同類型就可以來實現不同的功能,例如圖形繪制使用ElementNode節點,調整節點大小使用ResizeNode節點,框選內容使用FrameNode節點即可,那么在這里我們就先看一下ElementNode節點,用來表示實際節點。

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,

      });

    }

  };

}


轉自https://www.cnblogs.com/WindrunnerMax/p/18346501 


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