惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Register - Security
The Register - Security
The Cloudflare Blog
Last Week in AI
Last Week in AI
月光博客
月光博客
Security Latest
Security Latest
Project Zero
Project Zero
博客园 - 司徒正美
AWS News Blog
AWS News Blog
雷峰网
雷峰网
博客园 - 聂微东
C
Cybersecurity and Infrastructure Security Agency CISA
P
Palo Alto Networks Blog
人人都是产品经理
人人都是产品经理
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tor Project blog
Martin Fowler
Martin Fowler
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Microsoft Azure Blog
Microsoft Azure Blog
B
Blog RSS Feed
A
About on SuperTechFans
V
V2EX
P
Proofpoint News Feed
腾讯CDC
S
Schneier on Security
K
Kaspersky official blog
Google Online Security Blog
Google Online Security Blog
Recorded Future
Recorded Future
H
Hacker News: Front Page
Application and Cybersecurity Blog
Application and Cybersecurity Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
C
CERT Recently Published Vulnerability Notes
aimingoo的专栏
aimingoo的专栏
博客园 - 叶小钗
美团技术团队
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
V
Vulnerabilities – Threatpost
C
Cisco Blogs
PCI Perspectives
PCI Perspectives
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 【当耐特】
N
News and Events Feed by Topic
Google DeepMind News
Google DeepMind News
Hugging Face - Blog
Hugging Face - Blog
阮一峰的网络日志
阮一峰的网络日志
P
Privacy International News Feed
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
W
WeLiveSecurity
Spread Privacy
Spread Privacy
S
Securelist

程序员

V2EX 看到讨论"跨域"的帖子,那个她好像回来了 codex 今天真的是不稳定呀。 火山方舟 Coding Plan 慎买 刚问了大家 openclaw 和 hermes 在什么机器上面玩,求推荐一个机器 GPT-image-2 生成 AI 图片防伪有感 codex pro 5 小时限制已经严重缩水 逆天 Antigravity 动态 JSON 序列化对强类型语言很难吗? 自建了 GPT Coding Plan,遇到了定价问题,请教大家 大家都是在什么设备上玩 openclaw 以及 hermes 的呀? 软考还有一个月就考试了,你们学习了吗? 大伙用 AI 会考虑在 user scope 的 CLAUDE.md/AGENTS.md 里交代 AI 说中文吗 我发现程序员这个群体很大部分其实挺抠的 最近使用 cc 总会莫名其妙的返工, codex 不会 目前体验最好的远程 vibe 工具 想知道大佬们抓包遇到 ssl pinning 都是咋优雅的 解决的? - V2EX 工业软件的大佬们是怎么 vibe coding 的 - V2EX 最近 chrome 是不是有 bug 啊,一搜索就卡住 - V2EX 分布式异步系统在 vibe coding 下的困境 PHP Native AOT 编译器,支持将 PHP 代码编译为可执行文件,运算性能提高 150 倍 没想到 2026 年,还要浪费大量时间在跨域问题上 - V2EX DeepSeek V4 这周会出吗? 中转站正式试营 欢迎试用 不掺不假 小米 mimo 升级 v2.5,并且重置了额度 Jenkins, SCM 轮询完全不工作是啥问题啊 赛博斗蛐蛐, AI 模型的简单对比(白嫖版) 使用中转站要擦亮眼睛!不说别的,倍率计算 充值好乱。 买了火山的 Coding Plan 测试得出计费模式 给我的 AI 生成了简历和状态卡, 大家帮忙看下 Ta 能找到啥样的 腾讯云太不要脸了, token plan 上 glm5.1 和 minimax-m2.7,但是 coding plan 不上 - V2EX 目前自建梯子最强的 anytls 协议不是不不更新了? 刚发现 WSL2 可以通过 virtiofs 访问 NTFS 分区, IO 速度明显提升 v2board 还是其它面板更适合小团伙使用? 现在 AI 来了,如果有天不需要程序员? gpt-image-2 太顶了 你们 vibe 会让模型先输出方案来审核吗? GLM Coding Plan 调整老套餐并停止自动续订,受影响用户获赠 2 个月新套餐权益 为了浏览在线文档的时候能自定义高亮, Vibe 了一个网页高亮标记的工具 从 2 月开始用 Opus 4.6,到这几天切到 4.7,一些感悟和困惑 Github Copilot 停售之后还能买什么 收集了一下市面上常见的 Token Plan,可以给到大家一点参考 这个生图太牛了 中国的算力缺口这么大嘛?看到 2025 华为昇腾出货 81 万块,又看到各家 coding plan 不是停售就是限流 做 OPC 太难了,天天焦虑 阿里云 Coding Plan 增加动态限流,频繁暂停无法使用 马斯克 600 亿美元收购 Cursor codex 开始灰度 GPT 5.5 了 大家是如何使用 codex gpt-image-2 生图确实很顶啊,附带几张生成效果。 codex 的风评似乎在超过 Claude code? 智谱 GLM 宣布停止续订无周限额的老套餐 如何实现自我进化的 AI 的 Skills? 智谱(GLM)果然学习了阿里, 强制关闭了老用户套餐的自动续费 各位公司有除了智能客服外真正落地的 AI 应用吗? 热烈欢迎火山 Coding Plan 加入 GLM5.1 Kimi2.6 MiniMax2.7 阵容 请教大佬: claude API token 太烧钱了, minmax 还要兑换码,有没有合适的中转方案 开发一个面向普通用户(非程序员)的 Agent 软件,支持 skills、定时任务、对话功能,主要客户群体使用 Windows。感觉像是一个带前端的 Hermes,但是 Hermes 在 Windows 下运行不太友好,不适合非开发人员。 grok XAI 开始限制免费用户访问次数了吗? openspec 怎么配合 superpowers 使用? 阿里云的 coding plan 莫名奇妙被冻结了 程序员如何学习 ui 审美 开源了一个 AI Agent 认证工具 sig —— 让 AI Agent 安全访问外部系统 Vibe Coding 时代,该如何快速搭建一个 CRUD 平台? 感觉大多数的 coding plan 都是被龙虾薅死的。 有没有一起拼中转站的呀,找到一个中转站用了段时间感觉比较靠谱 搭个 AI 中转玩玩 Zeabur + Neon 50 块以内跑通 Coinepay:一张不能用的卡,扣了我 5 个月月费 Gemini in Chrome 支持日本了,为什么我的不生效? 跳板机/内网穿透方案,如何降低远程访问校内服务器延时? 现在有没有什么好的 AI 图片生成 3D 模型的方案? 关于科研 ai 作图 为什么现在 AI SDK 默认不支持异步? 建议 V2EX 加入一个 Ai App 或者 Vibe Coding 模块,把一堆 Ai 生成的产品都丢进去。 如果没用 coding plan 套餐了,再继续自费上班是不是就太傻了 AI 太烧钱了,有什么好的大模型组合方案? 阿里云百炼这波开始割韭菜了,下架 coding plan,改头换面 token plan 最新,腾讯云和阿里云 coding plan 购买页面均已下架,换成了 token plan 阿里云 CodingPlan 彻底没了,新上了 TokenPlan 大家看到 antigravity 那个配额监视器的广告了没? 真心发问,想学后端需要学啥? AI-Powered Log Analysis Tool - Pangu RCA 想自己写一个 code agent 玩玩,有什么开源项目可以参考吗? 腾讯云 Coding Plan 下架了 现在还有哪一个国产 Coding Plan 能买到吗? 106 行业短信签名申请新规范出台, 5 月 1 日起生效 最近上线了一个开发了半年的 shopify SAAS AI 就像那渣男渣女,平时哄着你温柔小意,关键时刻就坑你一波大的 大家在开发智能体时,都是怎么写系统提示词的? 现在大模型国内外直连越来越难买或拼车了,想试试中转站,大家有没什么推荐呀 一个人写了大半年 Android App,聊聊过程中的一些取舍,第二篇 智谱 coding plan 的使用 token 限制是多少? cli-proxy-api + 中转站领的鸡蛋 = 大模型免费用是否可行? gpt coding plan 一天试用(GPT 5.3codex 和 gpt 5.4) 大家用 AI 是订阅制多还是 API 按量付费多? 我用 AI 写了一个游戏,我发现我不想看代码,也看不懂代码了 AI 代码后面怎么维护,心智负担太大了 中年人爱上 AI 编程,就像爱上钓鱼 每月 AI 支出都超过生活费了 1000 行 rust 实现一个类似于 pytorch 的轻量级自动微分库
重构了自己 5 年前写的截图插件
MagicCoder · 2026-05-15 · via 程序员

1

前言

时隔 5 年,断断续续花了亿些时间完成了 js-screen-shot 项目的重构,这个插件最早的目标很简单:在 Web 端实现一个类似 QQ / 微信截图的功能。用户可以框选区域,然后在画布里画矩形、圆形、箭头、画笔、马赛克、文字,最后保存截图内容。

随着功能越来越多,再加上那会儿我的技术水平还不够好,架构设计的比较差,代码也不可避免地变复杂了。尤其是后面加入了 WebRTC 截屏、自定义工具栏、图片模式、Electron 适配等能力后,入口文件越堆越大。

插件从发布到现在,NPM 的周下载量保持在 1000+,同时有很多人反馈说画布里的内容无法二次编辑,于是就有了本次重构计划:让画布内的元素真正变成可管理、可选中、可移动、可重绘的对象。

本文就跟大家分享下我这次重构截图插件的整体思路、用到的技术点,以及过程中遇到的一些坑,欢迎各位感兴趣的开发者阅读本文。

为什么要重构

我们先来看下重构前的结构。

早期版本的核心目录大致如下:

src
├── main.ts
└── lib
    ├── main-entrance
    │   ├── CreateDom.ts
    │   ├── InitData.ts
    │   └── PlugInParameters.ts
    ├── split-methods
    ├── common-methods
    └── type
        └── ComponentType.ts

这个结构在功能少的时候是可以接受的,main.ts 负责串联整个截图流程,split-methods 存放绘制逻辑,common-methods 存放一些公共方法,InitData 管理插件运行时数据。

但是当功能继续增加后,它逐渐暴露出了几个问题。

main.ts 变得太重

未重构前,在 pre_release 分支中,main.ts 已经有 1500 多行代码。

里面同时处理了:

  • 插件初始化
  • DOM 创建与获取
  • 截图源加载
  • 鼠标按下、移动、抬起
  • 裁剪框绘制与拖拽
  • 工具栏绘制
  • 文字输入
  • 撤销
  • 保存与确认
  • WebRTC 截屏
  • 自定义工具栏

这会导致一个很直接的问题:任何功能都能改到入口文件。

比如:我只是想优化一下鼠标移动时的命中判断,都需要在 main.ts 里翻很久,因为它既包含画布状态,也包含工具栏状态,还包含 DOM 结构。

状态管理过于集中

旧版本里大量状态集中在 InitData.ts 中,通过模块级变量保存。

let dragging = false;
let toolClickStatus = false;
let selectedColor = "#F53340";
let toolName = "";
let penSize = 2;
let history: Array<Record<string, any>> = [];
let cutOutBoxPosition = {
  startX: 0,
  startY: 0,
  width: 0,
  height: 0
};

这种方式写起来很快,但后期维护会比较痛苦。

因为这些状态虽然都跟截图有关,但它们的职责并不一样,零零散散的包含了:

  • 裁剪框状态
  • 工具栏状态
  • 画布绘制状态
  • DOM 引用
  • 用户传入的配置

当它们全部放在一起时,代码很难看出一个状态到底属于哪个模块,也很难判断修改它会影响哪些地方。

画布内容不可二次编辑

旧版本的绘制逻辑是:“直接画到 canvas 上”。比如用户画了一个矩形,代码会立刻在 canvas 上画线,然后通过 ImageData 保存历史记录,这种方式做撤销很容易,但是要做“二次编辑”就是个大工程了。

小科普:因为 canvas 本身是位图,它并不知道上面哪个区域是矩形、哪个区域是箭头、哪个区域是文字。你一旦画上去,它就变成了像素。所以要支持选中、移动、缩放、删除,就必须额外维护一份“画布元素数据”。

重构后的目录结构

这次重构后,核心目录变成了下面这样:

从大入口到分层模块

src
├── main.ts
├── store
│   ├── CropBoxStore.ts
│   ├── DrawingDataStore.ts
│   ├── ScreenShotCanvasStore.ts
│   ├── TextInputStore.ts
│   ├── ToolBarStore.ts
│   ├── UserParamStore.ts
│   └── dom
├── lib
│   ├── application
│   ├── constants
│   ├── features
│   ├── shared
│   ├── type
│   └── utils
└── tests

入口文件 main.ts 从原来的 1500 多行降到了 200 多行,它现在更像是一个调度器,只负责把各个模块串起来。

export default class ScreenShot {
  constructor(options: ScreenShotOptions) {
    const normalizedOptions = normalizeScreenShotOptions(options);

    setPlugInParameters(normalizedOptions);
    new CreateDom(normalizedOptions);
    screenDomStore.initWebRtcDom();

    setOptionalParameter(normalizedOptions);
    screenDomStore.hydrateDomRefs();
    toolPanelDomStore.hydrateDomRefs();

    this.load(normalizedOptions);
  }
}

这样调整后,入口文件不再关心具体怎么画矩形、怎么判断箭头命中、怎么移动文字,它只负责组织流程。

我的重构思路

这次重构我主要按下面几个方向推进。

重构后的分层关系

按业务流程拆 application 层

application 目录负责插件运行流程。

src/lib/application
├── core
│   ├── ScreenFlowLoader.ts
│   ├── ScreenFrameDrawer.ts
│   ├── ScreenInitializer.ts
│   ├── ScreenShotModeExecutor.ts
│   ├── ScreenShotModeResolver.ts
│   ├── ScreenSourceManager.ts
│   └── UiCoordinator.ts
├── mouse
│   ├── CanvasMouseClickHandlers.ts
│   ├── CanvasMouseDownHandlers.ts
│   ├── CanvasMouseMoveHandlers.ts
│   ├── ToolbarDrawingHandler.ts
│   └── CustomToolEventBridge.ts
└── CreateDom.ts

这一层解决的是“截图流程怎么跑起来”的问题。

比如截图源加载,旧版本会在入口文件里判断 enableWebRtcimgSrcscreenFlow 等参数。现在我把这块整理成了截图模式解析和执行流程。

const plan = resolveScreenShotPlan();

executeLoadPlan(
  plan,
  mouseEvents,
  context,
  triggerCallback,
  cancelCallback,
  () => this.screenShotImageController,
  canvas => {
    this.screenShotImageController = canvas;
  }
);

这样后续如果要继续增加新的截图来源,不需要继续往 main.ts 里塞条件判断,而是扩展模式解析和执行器。

按功能拆 features 层

features 目录负责具体能力,比如绘制、配置处理、事件处理、历史记录。

src/lib/features/canvas
├── calculations
├── config
├── drawing
├── events
├── state
└── utils

这里面比较特殊的是 drawing 目录,它只处理 canvas 绘制。

drawing
├── DrawArrow.ts
├── DrawCircle.ts
├── DrawCutOutBox.ts
├── DrawImgToCanvas.ts
├── DrawLineArrow.ts
├── DrawMasking.ts
├── DrawMosaic.ts
├── DrawPencil.ts
├── DrawRectangle.ts
└── DrawText.ts

原来这些文件放在 split-methods 下面,名字虽然是拆开了,但从目录上看不出它们属于哪个业务模块。现在放到 features/canvas/drawing 后,职责会更明确:这些文件就是 canvas 绘制能力。

把可复用能力放到 shared 层

shared 目录放的是跨流程复用的能力。

比如:

src/lib/shared
├── canvas
│   ├── CanvasElementHitTest.ts
│   ├── CanvasElementSelection.ts
│   ├── CanvasElementTransform.ts
│   ├── CanvasElementToolbarSync.ts
│   ├── CustomCanvasElementUtils.ts
│   └── TextEditingController.ts
├── dom
├── platform
├── text
└── ui

这里最核心的是 shared/canvas

因为这次大版本更新的重点是“画布内元素可二次编辑”,选中、命中检测、拖拽、缩放、重绘这些逻辑并不属于某一个具体工具,它们是所有画布元素都要复用的能力。

使用 Store 拆分运行时状态

为了尽可能的轻量化,这次我选择引入 mobx 来做全局的状态管理。

以前 InitData 里面放了所有状态,现在拆成了多个 store:

src/store
├── CropBoxStore.ts
├── DrawingDataStore.ts
├── ScreenShotCanvasStore.ts
├── TextInputStore.ts
├── ToolBarStore.ts
├── UserParamStore.ts
└── dom
    ├── ScreenDomStore.ts
    └── ToolPanelDomStore.ts

这样拆完后,每个 store 的职责就比较清楚了。

  • CropBoxStore 负责裁剪框位置、拖拽、缩放等状态
  • ToolBarStore 负责当前工具、画笔大小、颜色、工具栏位置
  • DrawingDataStore 负责画布元素、历史记录、当前选中元素
  • UserParamStore 负责用户传入的配置
  • ScreenDomStore 负责截图相关 DOM 引用
  • ToolPanelDomStore 负责工具面板相关 DOM 引用

其中 DrawingDataStore 是这次改动的核心。

canvasElements: [],
activeElementId: null,
rectOperateIndex: null,
editingTextElementId: null,
pendingEditingTextElement: null

这些状态让画布上的内容从“像素”变成了“元素对象”。

画布元素二次编辑是怎么实现的

canvas 的难点在于:它不会帮你保存图形对象。

数据驱动画布重绘

当你在 canvas 上画了一个矩形,它只知道某些像素变成了红色,并不知道这里原来是一个矩形。

所以,这次我为每个绘制内容都维护了一份快照。

export interface BaseCanvasElement {
  id: string;
  x: number;
  y: number;
  drawNode?: boolean;
  dotRadius?: number;
}

export interface SquareElement extends BaseCanvasElement {
  width: number;
  height: number;
  borderWidth: number;
  color: string;
}

export interface TextElement extends BaseCanvasElement {
  width: number;
  height: number;
  color: string;
  fontSize: number;
  text: string;
  borderWidth: number;
}

画布中的元素会统一存到 canvasElements 中。

export type CanvasElement =
  | SquareElement
  | RoundElement
  | LineArrowElement
  | ArrowElement
  | PencilElement
  | MosaicElement
  | TextElement
  | CustomCanvasElement;

当用户绘制时,流程变成了这样:

  • 鼠标按下时创建当前元素 ID
  • 鼠标移动时绘制临时图形
  • 同步更新当前元素快照
  • 鼠标抬起时保存历史记录
  • 后续重绘时根据 canvasElements 重新画一遍

这样做以后,移动和缩放就不是去“移动像素”,而是修改元素数据,然后清空画布重新绘制。

clearCanvasSurface();
drawingDataStore.redrawCanvasElements();

这也是 canvas 编辑器比较常见的实现方式:数据驱动画布重绘

元素选中与命中检测

支持二次编辑后,第一个要解决的问题就是:鼠标点下去时,怎么知道点中了哪个元素?

元素二次编辑的交互链路

由于不同元素的命中规则是不一样的,矩形可以判断鼠标是否在边框附近,圆形要判断是否在椭圆边缘,箭头要判断鼠标是否在箭头线段附近,文字和画笔更适合用包围盒处理。

因此我把这块放到了 DrawingDataStoreCanvasElementSelection 中统一处理。

drawingDataStore.checkMouseInElement(x, y, elementId => {
  if (elementId) {
    selectCanvasElementBorder(elementId, dotRadius);
  }
});

选中元素后,会记录当前选中的元素 ID 。

drawingDataStore.updateActiveElementId(canvasElement.id);

并且给当前元素打上 drawNode 标识,重绘时根据这个标识画出选中边框和操作节点。

这块实现后,矩形、圆形、箭头、画笔、文字、自定义元素都可以进入同一套选中逻辑。

元素移动与缩放

移动元素的核心逻辑放在 CanvasElementTransform.ts

它并不直接操作 DOM ,也不直接关心鼠标事件,只接收当前鼠标位置、拖拽偏移量和目标元素 ID 。

export const moveCanvasElementOnCanvas = (
  mouseX: number,
  mouseY: number,
  dragOffset: { x: number; y: number },
  elementId: string | null
) => {
  const targetElement = resolveCanvasElement(elementId);
  if (targetElement == null) return;

  drawingDataStore.updateDrawStatus(true);
  clearCanvasSurface();

  // 根据元素类型更新位置
  // ...

  drawingDataStore.redrawCanvasElements();
};

矩形和文字这类元素比较简单,只需要更新 x / y

箭头就要麻烦一些,因为它除了包围盒,还有起点、终点、箭头顶点等信息。

画笔和马赛克也不能只更新包围盒,还要把内部的点位一起平移。

points: originalPoints.map(point => ({
  x: point.x + deltaX,
  y: point.y + deltaY
}))

这也是这次重构里比较容易踩坑的地方:不同元素看起来都叫移动,但内部数据结构并不一样。

如果强行用一套 x / y / width / height 处理所有元素,箭头、画笔、马赛克很快就会出问题。

自定义工具如何接入编辑逻辑

旧版本已经支持用户自定义工具栏,但那时的自定义工具是“把 canvas 暴露出去,让用户自己画”。

自定义元素接入编辑体系

这种方式虽然灵活,但它画出来的内容无法进入插件内部的编辑系统。

这次重构后,我增加了 customElementAdapterscustomElementApi

自定义元素需要满足一个基础结构:

export interface CustomCanvasElement extends BaseCanvasElement {
  customType: "custom";
  width: number;
  height: number;
  toolId?: number;
  toolName?: string;
  payload?: unknown;
}

插件内部会给自定义工具回调传入一组 API:

export type CustomCanvasElementApi = {
  addElement: (input: CustomCanvasElementInput) => CanvasElementSnapshot | null;
  updateElement: (element: CanvasElement) => void;
  removeElement: (id: string) => void;
  selectElement: (id: string) => boolean;
  getElement: (id: string) => CanvasElementSnapshot | undefined;
  getActiveElement: () => CanvasElementSnapshot | undefined;
  redraw: () => void;
};

用户自定义工具在绘制完成后,不再只是把内容画到 canvas 上,而是可以通过 addElement 把元素注册进插件内部。

同时,用户可以通过 adapter 告诉插件这个元素如何绘制、如何命中、如何移动、如何缩放。

export type CustomCanvasElementAdapter = {
  draw: (
    element: CustomCanvasElement,
    context: CanvasRenderingContext2D
  ) => void;
  hitTest?: (
    element: CustomCanvasElement,
    point: { x: number; y: number }
  ) => boolean;
  move?: (
    element: CustomCanvasElement,
    delta: { x: number; y: number },
    bounds: CropBoxBounds
  ) => CustomCanvasElement | void;
  resize?: (
    element: CustomCanvasElement,
    handleIndex: number,
    point: { x: number; y: number },
    bounds: CropBoxBounds
  ) => CustomCanvasElement | void;
};

这样做之后,自定义元素就不再是插件体系外的“自由绘制内容”,而是可以进入统一的选中、移动、重绘、删除逻辑。

比如五角星这种自定义图形,就可以通过 draw 负责画星星,通过 hitTest 判断鼠标是否点中,通过 move 控制移动边界。

有关此处的使用,详细文档请移步:工具栏模块化扩展

优化截图源配置定义

这次还顺手整理了截图源的配置传入字段,以前配置项比较分散,比如:

  • enableWebRtc
  • screenFlow
  • imgSrc
  • wrcWindowMode

这些参数都是在描述截图来源和渲染方式,但分散在多个字段里,后面继续扩展会越来越难理解。

因此现在增加了一个新的 capture 配置。

export type ScreenShotCaptureOptions = {
  source?: "display-media" | "injected-stream" | "dom" | "image";
  render?: "browser-frame" | "window-frame";
  stream?: MediaStream;
  imageSrc?: string;
};

处于兼容性考虑,插件内部会先把新旧参数统一归一化。

const normalizedOptions = normalizeScreenShotOptions(options);

如果用户继续使用旧参数,插件仍然兼容,只是内部会统一转成新的截图模式。

这块的好处是后面如果继续扩展截图来源,比如增加新的图片输入方式,或者增加某种自定义渲染模式,不需要再让入口文件继续膨胀。

这次遇到的几个坑

整个重构过程中自然遇到了一些问题,这里简单跟大家分享下。

选中态必须在正确时机清理

做元素编辑时,一开始很容易出现一个 bug:用户选中了一个旧元素,然后开始画新元素,旧元素的选中边框还在。

这个问题本质上是状态没有归属清楚。

现在的处理方式是:开始绘制新元素前,需要先清理当前选中元素的状态。

drawingDataStore.updateActiveElementId(null);
drawingDataStore.updateRectOperateIndex(null);
drawingDataStore.resetCanvasElementNodeState();

否则用户看到的就是“我明明在画新矩形,但旧元素还处于选中状态”。

这类问题不是 canvas 绘制问题,而是交互状态问题。

拖拽已有元素后,要切换当前选中元素

另一个问题是:如果当前已经选中了 A 元素,然后用户直接拖拽 B 元素,拖拽结束后应该选中 B 。

这个逻辑看起来很自然,但实现时要注意鼠标按下、移动、抬起之间的状态传递。

现在我用一个 pointerSession 保存本次指针操作的信息。

const pointerSession = {
  prevElementId: null,
  dragOffset: { x: 0, y: 0 },
  transformingExisting: false
};

这样在 mousedown 时确认命中的元素,在 mousemove 时移动这个元素,在 mouseup 时完成本次编辑状态同步。

文字编辑不能只当普通矩形处理

设计文字元素结构对象的时候,它看起来也有 x / y / width / height,于是我就想把它纳入普通矩形去,实际做的时候,发现它还涉及输入框、文本内容、字号、二次编辑。就出现了两个问题:

  • 空文本元素被当成宽高为 0 的无效元素删掉
  • 二次编辑时文本输入框和画布文本状态不同步

最后只好将文字相关逻辑拆到 shared/textTextEditingController 中,单独处理文本输入、提交、点击编辑等流程。

canvas 历史记录不能只保存 ImageData

旧版本的撤销主要依赖 ImageData

但是做二次编辑后,只保存 ImageData 不够了。

因为画布元素还存在于 canvasElements 中,如果撤销时只恢复像素,不恢复元素数据,用户下一次选中、移动、删除时就会出现数据和画面不一致的问题。

所以现在历史记录需要同时保存画布像素和元素快照。

{
  data: imageData,
  canvasElements: [...]
}

注意:这点非常重要。只要你的 canvas 是“可编辑画布”,就不能只把它当成图片处理。

构建和开发体验优化

除了业务代码,这次也顺手调整了构建体验。

包管理器从原来的 yarn 切到了 pnpm,并在 package.json 中固定了版本。

{
  "packageManager": "[email protected]"
}

Rollup 构建也做了一些优化,比如显式配置 babelHelpers,减少无意义警告;开发构建时输出更清晰的进度信息;启动时打印项目名,让终端输出更容易识别。

babel({
  babelHelpers: "bundled"
})

这些不是核心功能,但对维护项目很重要。

我做事情喜欢做到极致,在开发阶段我会消除所有的警告,让后续的调试、构建、发布都尽量顺手。

项目地址

这次重构最大的变化,是把截图插件从“过程式地操作 canvas”调整成了“用数据描述画布元素,再根据数据重绘 canvas”。

简单来说,就是从:

用户操作 -> 直接画到 canvas -> 保存像素历史

变成:

用户操作 -> 更新元素数据 -> 清空画布 -> 根据元素数据重绘 -> 保存像素和元素快照

这个变化带来的收益非常明显:

  • 入口文件变轻
  • 运行时状态更清晰
  • 内置元素完美支持二次编辑
  • 自定义元素可以接入编辑体系
  • 截图源配置更统一
  • 后续功能扩展有了更明确的位置

当然,代价也有。

二次编辑会让 canvas 逻辑复杂很多,尤其是不同元素的命中检测、移动、缩放、历史记录同步,都需要单独处理。

但从长期维护角度看,这是值得的。

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于神奇的程序员公众号,未经许可禁止转载💌