Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)
Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)
在前边我们聊了数据结构的设计和剪贴板的数据操作,那么这些操作都还是比较倾向于数据相关的操作,那么我们现在就来聊聊基本的图形绘制以及图形状态管理。
- 在线编辑: https://windrunnermax.github.io/CanvasEditor
- 开源地址: https://github.com/WindrunnerMax/CanvasEditor
关于Canvas
简历编辑器项目的相关文章:
- 社区老给我推Canvas,我也学习Canvas做了个简历编辑器
- Canvas图形编辑器-数据结构与History(undo/redo)
- Canvas图形编辑器-我的剪贴板里究竟有什么数据
- Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)
- Canvas简历编辑器-Monorepo+Rspack工程实践
- 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,
});
}
};
}
最后
在这里我们聊了聊如何抽象基本的图形绘制以及状态的管理,因为我们的需求在这里所以我们的图形绘制能力会设计的比较简单,而状态管理则是迭代了三个方案才确定通过轻量DOM
的方式来实现,那么再往后,我们就需要聊一聊如何实现 层级渲染与事件管理 的能力设计。
Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)的更多相关文章
- 图形绘制 Canvas Paint Path 详解
图形绘制简介 Android中使用图形处理引擎,2D部分是android SDK内部自己提供,3D部分是用Open GL ES 1.0.大部分2D使用的api都在android.grap ...
- 使用原生JavaScript的Canvas实现拖拽式图形绘制,支持画笔、线条、箭头、三角形、矩形、平行四边形、梯形以及多边形和圆形,不依赖任何库和插件,有演示demo
前言 需要用到图形绘制,没有找到完整的图形绘制实现,所以自己实现了一个 - - 一.实现的功能 1.基于oop思想构建,支持坐标点.线条(由坐标点组成,包含方向).多边形(由多个坐标点组成).圆形(包 ...
- javascript制作公式编辑器,函数编辑器和图形绘制
自己是电子信息方向的,因此总是需要处理大量的电路实验.电路数据和电路仿真处理,每次处理数据时候还需要同样的数据很多遍, 又需要关于电路的频率响应和时域响应情况,所以一直有做一个这样公式编辑器的打算了. ...
- 原生js实现Canvas实现拖拽式绘图,支持画笔、线条、箭头、三角形和圆形等等图形绘制功能,有实例Demo
前言 需要用到图形绘制,没有找到完整的图形绘制实现,所以自己实现了一个 - - 演示地址:查看演示DEMO 新版本支持IE5+(你没看错,就是某软的IE浏览器)以上任意浏览器的Canvas绘图:htt ...
- HTML5图形绘制学习(1)-- Canvas 元素简介
Canvas元素是HTML5中新增的一个专门用来进行图形绘制的元素.和其名称Canvas一样,它就相当于一个画布,我们可以在其上描绘各种图形. 这里所说的绘制图型,不是指我们可以进行可视化的图形绘制, ...
- [html] 学习笔记-Canvas图形绘制处理
使用Canvas API 可以将一个图形重叠绘制在另外一个图形上,也可以给图形添加阴影效果. 1.Canvas 图形组合 通过 globalCompositeOperation = 属性 来指定重叠效 ...
- 小强的HTML5移动开发之路(6)——Canvas图形绘制基础
来自:http://blog.csdn.net/dawanganban/article/details/17686039 在前面提到Canvas是HTML5中一个重要特点,canvas功能非常强大,用 ...
- canvas图形绘制
前面的话 前面分别介绍了canvas的基础用法和进阶用法,本文将使用canvas的各种语法进行图形绘制 绘制线条 [绘制线条] 下面来尝试绘制一段线条 <canvas id="draw ...
- Canvas 给图形绘制阴影
/** * 图形绘制阴影 */ function initDemo6() { var canvas = document.getElementById("demo6"); if ( ...
- 自定义控件之Canvas图形绘制基础练习-青春痘笑脸^_^
对于自定义控件的意义不言而喻,所以对它的深入研究是很有必要的,前些年写过几篇关于UI效果的学习过程,但是中途比较懒一直就停滞了,而对于实际工作还是面试来说系统深入的了解自定义控件那是很有必要的,所以接 ...
随机推荐
- HTML5画布-小球碰撞
Tips:当你看到这个提示的时候,说明当前的文章是由原emlog博客系统搬迁至此的,文章发布时间已过于久远,编排和内容不一定完整,还请谅解` HTML5画布-小球碰撞 日期:2017-7-18 阿珏 ...
- CF1854E Games Bundles
乱搞题 设个 \(dp[i]\) 表示和为 \(i\) 的子序列个数,那么转移是容易的, \(dp[j]+=dp[j-i]\) ,然后就判下 \(dp[60]+dp[60-i]\) 是否大于 \(m\ ...
- linux scp自动填充密码脚本
在linux上使用scp命令传输文件时,每传输一次,都要填写目标服务器的登录密码,十分麻烦. 配置系统密钥又比较复杂,于是想到的使用expect写一个自动填充密码的脚本,脚本内容如下: scp.sh ...
- python后端model模板
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contri ...
- 洛谷P1004
洛谷P1004方格取数 题目大意 本题简要意思就是一个人从一个数字矩阵的左上角走到右下角,只能向下和向右走,拿完的数对应位置变成0,并且这个人要走两次,需要计算两次所拿数的最大值 Train of t ...
- 小程序-浅谈云函数获取数据和云数据库api获取数据的区别
区别:在于条数的限制,云数据库api获取数据限制20条以内,云函数限制100条以内 index.wxml <button bindtap="shujukuget">数据 ...
- [oeasy]python0010_hello_world_unix_c历史迷因
Hello World! 回忆上次内容 我们这次设置了断点 设置断点的目的是更快地调试 调试的目的是去除 bug 别害怕 bug 一步步地总能找到 bug 这就是程序员基本 ...
- Python elasticsearch-py类库基础用法
实践环境 https://pypi.org/project/elasticsearch/ pip install elasticsearch==7.6.0 离线安装包及依赖包下载地址: https:/ ...
- Odoo 通过Javascript调用模型中自定义方法
实践环境 Odoo 14.0-20221212 (Community Edition) 代码实现 在js脚本函数中调用模型中自定义方法: this._rpc({ model: 'demo.wizard ...
- python中的字符串和列表
name="1" name='1' name="""1""""" name='''1''' #都为正 ...