在线白板 - 如何从零实现一个自由画笔

本文主要介绍白板工具中比较有代表性的一个功能:自由画笔(freehand),主要介绍两块的内容,一个是 freehand 涉及的一些绘图知识,比如如何确定平滑曲线、不同画笔的特点等,另外一个就是分享下如何从零写一个画笔功能。

本文介绍的画笔功能实现是一个真实的例子,对应的就是开源白板工具 Drawnix 中画笔的实现,架构上它是基于 Plait 框架的插件机制,如果你要自己实现一个类似的画笔,实现方式和思路应该是差不多的。

01 平滑曲线技术

曲线技术关注的是如何更好的显示一系列点(用户使用画笔过程中其实是记录了一系列点),更好指的是不要有太多锯齿、尽量平滑、尽量贴近用户实际的绘制路径等等。

这是一个非常小的技术点,但是想把它做好也有困难,Excalidraw 在早期直接使用了 roughjs 的 curve 曲线绘制,而 TLDraw 作者则是研究的相对深入封装了一个库( github.com/steveruizok... ),可以根据一系列点构造一个完美的路径,到后面 Excalidraw 发起人请了 TLDraw 的发起人帮 Excalidraw 完善画笔功能,最终 Excalidraw 和 TLDraw 现在都是使用 perfect-freehand 渲染画笔的路径。

我开始也不了解如何构造平滑的曲线,我是根据 AI 提供的思路一点一点试出来的,一方面是后期通过平滑算法对点进行平滑处理,另一方面是在用户绘制过程中对点进行纠正。

① Catmull-Rom 样条

Catmull-Rom 样条是一种 插值型三次样条 算法,常用于生成通过给定控制点的平滑曲线(曲线严格经过所有控制点)。这个算法目前用在 Drawnix 白板 -> 箭头 -> 曲线,基于箭头上的控制点构造经过控制点的平滑曲线。

② 高斯平滑

这个算法以前也没有接触过,是基于 AI 给出的方案,然后我尝试了下发现平滑效果很不错,于是就在自由画笔功能中使用了,用于后期平滑处理。

高斯平滑是一种基于 高斯函数 的线性滤波技术,主要用于 去噪模糊化 信号(如图像、音频等)。其核心思想是通过对数据邻域进行加权平均, 抑制高频噪声 ,同时保留主要特征。

③ 多因子自适应平滑

这是一个复杂的手写笔迹平滑算法,是结合 ai 和自己的思路攒的一个处理算法,主要思路是在用户手写过程中根据上下文、速度、采样率、压力、倾斜等等对输入g过程中产生的点进行平滑处理。

结果

结合输入过程中的「 多因子自适应平滑 」处理和后期的「 高斯平滑 」,目前 Drawnix 的画笔可以在中文、英文输入、鼠标、Pencil 模式等多种情况下在线条平滑和保留真实输入笔记上达到一个微妙的平衡。

可以看看下面操作的效果

02 不同画笔特点

目前我所了解的到画笔大概有四种风格(参考了一些 Pad 中笔记类的 App):

  • nibPen(钢笔) 适合需要正式感和艺术感的场合
  • feltTipPen(水性笔) 适合日常记录和绘画
  • artisticBrush(艺术笔) 适合艺术创作和书法
  • markerHighlight(马克笔) 主要用于重点标记和文本突出显示

通常类似 Excalidraw 和 TLDraw 都只实现一种最常用的水性笔,开源白板工具 Drawnix 目前也是一样仅仅实现了 feltTipPen 风格的画笔,其它类型的画笔要不要支持可能需要等到以后再考量,实现的技术细节也有待研究。

03 实现画笔功能

简单介绍下实现 Drawnix 白板的画笔需要做什么,Drawnix 白板的是基于 Plait 框架实现,Plait 底层设计了插件机制,可以方便的实现任何的交互式的白板功能,它底层抽象出了 pointerDown、pointerMove、pointerMove、isHit、insertFragment 等等的可重写函数,可以方便的实现交互式的创建和接入画板的编辑特性。

实现流程图五步走:

  1. 定义数据类型
  2. 实现绘制 Generator
  3. 画图元素逻辑 FlavourComponent
  4. 实现交互式场景插件
  5. 接入可编辑特性

一、定义数据类型

现在所有的开发都是数据驱动的,由数据去表达图形元素的点位、样式、特征等信息

ini 复制代码
export interface Freehand {
  type: 'freehand';
  points: Point[];
  shape: FreehandShape;
  // 基础属性
  fill?: string; // 闭合的才有这个属性
  strokeColor?: string;
  strokeWidth?: number;
  strokeStyle?: StrokeStyle;
  angle?: number;
}
// 画笔的类型
export enum FreehandShape {
  nibPen = 'nibPen', // 钢笔
  feltTipPen = 'feltTipPen', // 水性笔
  artisticBrush = 'artisticBrush', // 艺术笔
  markerHighlight = 'markerHighlight', // 马克笔
}

主要属性说明:

  1. type - 是框架定义的用于标识插件数据类型的字段
  2. points - 描述画笔绘制的元素的点位信息
  3. shape - 定义画笔的风格

二、绘制 Generator

这部分主要实现基于数据绘制图形元素,Plait 框架抽象了一个元素绘制的 Generator 基类,不同的元素及其附属要素绘制时只需要集成这个基类,然后提供必要绘制逻辑既可以完成元素的绘制逻辑。

scala 复制代码
export class FreehandGenerator extends Generator<Freehand, FreehandData> {
  protected draw(
    element: Freehand,
    data?: FreehandData | undefined
  ): SVGGElement | undefined {
    let option: Options = { ...DefaultFreehand };
    const g = PlaitBoard.getRoughSVG(this.board).curve(element.points, option);
    setStrokeLinecap(g, 'round');
    return g;
  }

  canDraw(element: Freehand, data: FreehandData): boolean {
    return true;
  }
}

Plait 框架主要使用 svg 绘制元素,大部分的绘制基于 roughjs 完成,自由画里面的水性笔目前就简单的使用了 roughjscurve 方法完成绘制。

三、FlavourComponent

继承 Plait 中的基类 ElementFlavour(ElementFlavour 意味着元素风味,底层会控制它的生命周期调用,决定插件元素渲染成什么样子,通过重写 drawElement 指定插件数据对应的 ElementFlavour),freehand 的实现包括:元素绘制、选中状态绘制等(具体的绘制依赖对应的 generator)。

kotlin 复制代码
export class FreehandComponent
  extends CommonElementFlavour<Freehand, PlaitBoard>
  implements OnContextChanged<Freehand, PlaitBoard>
{
  constructor() {
    super();
  }

  activeGenerator!: ActiveGenerator<Freehand>;

  generator!: FreehandGenerator;

  initializeGenerator() {
    this.activeGenerator = createActiveGenerator(this.board, {
      getRectangle: (element: Freehand) => {
        return RectangleClient.getRectangleByPoints(element.points);
      },
      getStrokeWidth: () => ACTIVE_STROKE_WIDTH,
      getStrokeOpacity: () => 1,
      hasResizeHandle: () => {
        return hasResizeHandle(this.board, this.element);
      },
    });
    this.generator = new FreehandGenerator(this.board);
  }
  // 初始渲染
  initialize(): void {
    super.initialize();
    this.initializeGenerator();
    this.generator.processDrawing(this.element, this.getElementG());
  }
  // 数据更新后的更新渲染
  onContextChanged(
    value: PlaitPluginElementContext<Freehand, PlaitBoard>,
    previous: PlaitPluginElementContext<Freehand, PlaitBoard>
  ) {
    // ...
  }
  // 插件元素删除后销毁渲染的内容
  destroy(): void {
    super.destroy();
    this.activeGenerator?.destroy();
  }
}

四、交互式创建

这部分主要实现基于数据绘制图形元素,Plait 框架抽象了一个元素绘制的 Generator 基类,不同的元素及其附属要素绘制时只需要集成这个基类,然后提供必要绘制逻辑既可以完成元素的绘制逻辑。

交互式创建画笔元素采用经典的组合:按下(pointerDown)- 移动(pointerMove)- 抬起(pointerUp),Plait 底层抛出了画板的 pointerDown、pointerMove、pointerUp 事件钩子,可以方便的重写这些钩子实现自由画(使用 pointerXXX 而不是 mouseXXX 是因为 pointerXXX 事件可以兼容移动端)。

ini 复制代码
export const withFreehandCreate = (board: PlaitBoard) => {
  const { pointerDown, pointerMove, pointerUp, globalPointerUp } = board;

  let isDrawing: boolean = false;

  let points: Point[] = [];

  const generator = new FreehandGenerator(board);

  let temporaryElement: Freehand | null = null;

  const complete = (cancel?: boolean) => {
    if (isDrawing) {
      const pointer = PlaitBoard.getPointer(board) as FreehandShape;
      temporaryElement = createFreehandElement(pointer, points);
    }
    if (temporaryElement && !cancel) {
      Transforms.insertNode(board, temporaryElement, [board.children.length]);
    }
    generator?.destroy();
    temporaryElement = null;
    isDrawing = false;
    points = [];
  };

  board.pointerDown = (event: PointerEvent) => {
    const freehandPointers = getFreehandPointers();
    const isFreehandPointer = PlaitBoard.isInPointer(board, freehandPointers);
    if (isFreehandPointer && isDrawingMode(board)) {
      isDrawing = true;
      const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
      points.push(point);
    }
    pointerDown(event);
  };

  board.pointerMove = (event: PointerEvent) => {
    if (isDrawing) {
      generator?.destroy();
      if (isDrawing) {
        const newPoint = toViewBoxPoint(
          board,
          toHostPoint(board, event.x, event.y)
        );
        points.push(newPoint);
        const pointer = PlaitBoard.getPointer(board) as FreehandShape;
        temporaryElement = createFreehandElement(pointer, points);
        generator.processDrawing(
          temporaryElement,
          PlaitBoard.getElementActiveHost(board)
        );
      }
      return;
    }
    pointerMove(event);
  };

  board.pointerUp = (event: PointerEvent) => {
    complete();
    pointerUp(event);
  };

  board.globalPointerUp = (event: PointerEvent) => {
    complete(true);
    globalPointerUp(event);
  };

  return board;
};

特殊说明:

  1. pointerDown 中基于 isFreehandPointer 确定是否可以开启画笔模式(当用户点击工具栏中的自由画 icon 时,设置画板的 pointer 到画笔模式)。
  2. ponterMove 中的绘制处理复用前面写的 generator。
  3. pointerUp 处理绘画结束,globalPointerUp 辅助处理用户离开画布抬起的场景。

五、可编辑特性

白板编辑器和富文本编辑器一样也是前端领域交互非常复杂的场景,因为它同样包含不少编辑特性,比如选中、拖动、调整大小、拷贝/粘贴、旋转等,基于 Plait 框架可以快速赋予插件元素这些编辑特性。

  1. getRectangle 确定自由画元素的边界
  2. 选中对应可重写函数 isRectangleHit(验证框选)、isHit(验证点选),选中逻辑由框架底层实现
  3. 拖动对应可重写函数 isMovable,指定插件元素是否可拖动,拖动逻辑由底层实现
  4. 删除对应可重写函数 getDeletedFragment,用于指定触发删除操作时那些元素需要被删除(可以控制连带删除一些关联元素,freehand 无相关逻辑)
  5. 拷贝对应可重写函数 buildFragment,用于构建存放到粘贴板中的数据(需要做一些相对位置的计算,基于底层函数 buildClipboardData 即可)
  6. 粘贴对应可重写函数 insertFragment,执行插件元素的插入操作,借助底层框架方法即可(需要替换 id 构建新的 points 等)。
  7. Undo/Redo 框架层实现
  8. 调整大小、旋转功能基于 @plait/draw 流程图插件完成(需要把 freehand 配置为 @plait/draw 的 customGeometryTypes)。

with-freehand(接入基础编辑特性):

ini 复制代码
export const withFreehand = (board: PlaitBoard) => {
  const {
    getRectangle,
    drawElement,
    isHit,
    isRectangleHit,
    getOneHitElement,
    isMovable,
    isAlign,
  } = board;

  board.drawElement = (context: PlaitPluginElementContext) => {
    if (Freehand.isFreehand(context.element)) {
      return FreehandComponent;
    }
    return drawElement(context);
  };

  board.getRectangle = (element: PlaitElement) => {
    if (Freehand.isFreehand(element)) {
      return RectangleClient.getRectangleByPoints(element.points);
    }
    return getRectangle(element);
  };

  board.isRectangleHit = (element: PlaitElement, selection: Selection) => {
    if (Freehand.isFreehand(element)) {
      return isRectangleHitFreehand(board, element, selection);
    }
    return isRectangleHit(element, selection);
  };

  board.isHit = (element, point) => {
    if (Freehand.isFreehand(element)) {
      return isHitFreehand(board, element, point);
    }
    return isHit(element, point);
  };

  board.getOneHitElement = (elements) => {
    const isAllFreehand = elements.every((item) => Freehand.isFreehand(item));
    if (isAllFreehand) {
      return getHitDrawElement(board, elements);
    }
    return getOneHitElement(elements);
  };

  board.isMovable = (element) => {
    if (Freehand.isFreehand(element)) {
      return true;
    }
    return isMovable(element);
  };

  board.isAlign = (element) => {
    if (Freehand.isFreehand(element)) {
      return true;
    }
    return isAlign(element);
  };

  (board as PlaitOptionsBoard).setPluginOptions<WithDrawOptions>(
    WithDrawPluginKey,
    { customGeometryTypes: [FREEHAND_TYPE] }
  );

  return withFreehandFragment(withFreehandCreate(board));
};

with-freehand-fragment(接入复制粘贴特性):

ini 复制代码
export const withFreehandFragment = (baseBoard: PlaitBoard) => {
  const board = baseBoard as PlaitBoard;
  const { getDeletedFragment, buildFragment, insertFragment } = board;

  board.getDeletedFragment = (data: PlaitElement[]) => {
    const freehandElements = getSelectedFreehandElements(board);
    if (freehandElements.length) {
      data.push(...freehandElements);
    }
    return getDeletedFragment(data);
  };

  board.buildFragment = (
    clipboardContext: WritableClipboardContext | null,
    rectangle: RectangleClient | null,
    operationType: WritableClipboardOperationType,
    originData?: PlaitElement[]
  ) => {
    const freehandElements = getSelectedFreehandElements(board);
    if (freehandElements.length) {
      const elements = buildClipboardData(
        board,
        freehandElements,
        rectangle ? [rectangle.x, rectangle.y] : [0, 0]
      );
      clipboardContext = addOrCreateClipboardContext(clipboardContext, {
        text: '',
        type: WritableClipboardType.elements,
        elements,
      });
    }
    return buildFragment(
      clipboardContext,
      rectangle,
      operationType,
      originData
    );
  };

  board.insertFragment = (
    clipboardData: ClipboardData | null,
    targetPoint: Point,
    operationType?: WritableClipboardOperationType
  ) => {
    const freehandElements = clipboardData?.elements?.filter((value) =>
      Freehand.isFreehand(value)
    ) as Freehand[];
    if (freehandElements && freehandElements.length > 0) {
      insertClipboardData(board, freehandElements, targetPoint);
    }
    insertFragment(clipboardData, targetPoint, operationType);
  };

  return board;
};

其它

自由画插件元素的很多特性与 @plait/draw 插件 geometry 元素一致,所以 @plait/draw 插件提供了可扩展能力,可以将 freehand 的图形元素配置为它的扩展类型(CustomGeometry)。

总结

总结来讲实现一个画笔,尤其是包含各种白板编辑特性的画笔还是有些复杂的,但是借助 Plait 框架就可以让这个过程变得简单一些,而且由于框架的约束,可以使代码结构更清晰容易理解,当然这有一个前提是需要理解 Plait 白板框架。

欢迎各位朋友试用、提出宝贵意见,在线地址:drawnix.com

也欢迎 star 支持 GitHub - plait-board/drawnix: 开源白板工具(SaaS),一体化白板,包含思维导图、流程图、自由画等。All in one open-source whiteboard tool with mind, flowchart, freehand and etc.

相关推荐
一只大侠的侠10 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
猫头虎14 小时前
如何排查并解决项目启动时报错Error encountered while processing: java.io.IOException: closed 的问题
java·开发语言·jvm·spring boot·python·开源·maven
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端