百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

图形编辑器:基于 canvas的所见即所得文本编辑

myzbx 2025-02-09 13:28 16 浏览

大家好,我是前端西瓜哥。

前段时间给我的 suika 图形编辑器重写了文本编辑功能,基本支持了所见即所地编辑文本了,这篇文章总结一下实现这个功能需要做的一些工作。

suika 图形编辑器 github 地址:

https://github.com/F-star/suika

线上体验:

https://blog.fstars.wang/app/suika/

简单演示,使用的字体是 “得意黑”。

作为一款图形编辑器,自然是少不了文本的输入和编辑功能。

为了提高性能,图形编辑器通常使用 canvas 实现,但文本编辑如果要用 canvas 实现是不小的工作量。

对此,一种简单的方式是进入编辑状态时隐藏原来的文本图形,然后在其正上方通过绝对定位放上一个 input 或 texture,如果还想支持富文本,也可以找一个富文本编辑器挂载在 div 容器元素上,再把这个 div 做绝对定位。

这种借助 html 元素的方式,在简单场景倒是没什么问题。

但它有如下缺陷:

  1. 无法保持文本图形原来所在的层级,只能提升到顶层。这样就不能所见即所得的看文本图形和它上方图形效果叠加的效果,比如没法实时观察并调整毛玻璃滤镜下文字渲染效果。
  2. canvas 和 html 渲染效果不一致。图形编辑器的文本渲染可能做了一些加强,比如右上小标、文字使用渐变填充、图片填充、虚线描边等各种增强功能。这是基于 html 的文本编辑器是无法模拟的,且不同浏览器的 html 渲染也有微妙不同。

出于精益求精的精神,我们尝试在图形编辑器下,做一个基于 canvas 2d 的简单文本编辑器。

文本图形

文本图形实体。

class TextGraphis {
  attrs: {
    content: string
  }
}

这里我们就不这么复杂,用纯文本,提供一个 content 属性,保存字符串形式的文本内容。

字形 box

然后我们需要计算 content 中每个字形(glyph)的宽高,之后需要用它们来定位文字游标的位置。

interface IGlyph {
  position: IPoint;
  width: number;
  height: number;
  // box 顶部到基线的距离
  fontBoundingBoxAscent: number;
}

注意这里说的不是每个字符(char),这是因为数据上的多个字符的表达,在渲染时可能会合并为一个。

JavaScript 支持 Unicode,一个 Unicode 字符可能会占用 2 个或更多码点 的空间,比如 ""。

"".length 的返回值是 2,虽然看起来只有一个字符。 其实等价于 \uD842\uDFB7

一个 Unicode 可以简单和一个 glyph 划等号(暂不考虑连字 ligature)。

emoji 也是 Unicode,对于 canvas 2d,如果字体的字符集中有对应的 emoji,会将这个 emoji 渲染出来,否则用操作系统提供的 emoji 进行渲染。

我们没法用字符串的 length 属性来判断 glyph 的数量。

我们可以用 for...of 来拿到每个 Unicode 字符,然后用 ctx.measureText() 方法拿到每个 glyph 的 box 信息。

const glyphs = [];

for (const c of content) {
  const textMetrics = ctx.measureText(c);
  glyphs.push({
    position: { ...position },
    width: textMetrics.width,
    height:
      textMetrics.fontBoundingBoxAscent + textMetrics.fontBoundingBoxDescent,
    fontBoundingBoxAscent: textMetrics.fontBoundingBoxAscent,
  });
  position.x += textMetrics.width;
}

fontBoundingBoxAscent 为 box 顶部距离文本基线(baseline)的距离,fontBoundingBoxDescent 为 box 底部距离文本基线的距离,二者相加即为 box 的高度。

fontBoundingBoxAscent 属性我们也保存下来,canvas 2d 渲染是基于基线的,我们需要这个值做垂直位移。

position 记录了 glyph 的左上角到文本起点位置的距离。

比较遗憾的是,canvas 2d 拿不到字体的 kerning 字距表。

我也有想到一个办法,比较曲折,就是分别单独计算两个字符的各自的宽度,然后再计算两个字符拼接后的宽度。

求出这两个宽度的差值,便是这两个字符的字距了。

这里可以优化一下,相同的 glyph 没有必要重新计算,可以用一个 map 缓存起来。

Range

我们拿到了字符串中每个 glyph 的几何信息,就能正确的位置渲染 cursor 光标了。

首先我们定义一个 RangeManager 类,来 维护文本中的光标线和选中信息

class RangeManager {
  private range = { start: 0, end: 0 };
  
  setRange(range) {
    this.range = {
      start: range.start,
      end: range.end,
    };
  }

  getRange() {
    return { ...this.range };
  }
}

成员属性 range 的 start 表示被 编辑文本上选区的起始索引值,end 表示选区的结束位置。

当 start 和 end 值相等时,在最上层会显示一个闪烁的竖直光标,位置为对应 glyph 的左侧。

闪烁动画可能会导致渲染不断被触发,需要做一些优化,目前 suika 图形编辑器上的文字光标目前并不会闪烁。

另外我们还有一个方案,就是像 canvas editor 一样,用一个带动画的 div 模拟,反正它都是要放在最顶部的。

如果 start 和 end 的值不同,则是将这个区间内绘制一个半透明的矩形,同样是放到最顶层。

start 的值并不要求一定小于 end ,是可以大于 end 的。后面我们用光标选中字符时需要用到这个特性。

但有时候我们希望拿到基于左右位置拿到两个索引值,用于正确切割出 range 左右两侧的子字符串。

所以我们要加个 getSortedRange 方法。

class RangeManager {
  // ...

  getSortedRange() {
    const rangeLeft = Math.min(this.range.start, this.range.end);
    const rangeRight = Math.max(this.range.start, this.range.end);
    return { rangeLeft, rangeRight };
  }
}

光标位置计算

如果 range.start 和 range.end 相等,我们会渲染一条光标线,为此我们需要计算这条线的 top 和 bottom 位置,见下图。

做法是拿到正在被编辑的文本图形实体的字形信息,即前面提到的字形 box 数组。

const glyphInfos = textGraphics.getGlyphs();

根据 range.start 的索引值找到匹配的 glyph 项,对应的 position 是相对文本实体的本地坐标,我们需要应用文本的矩阵得到场景坐标。

又因为我们需要把光标渲染在最顶层,也就是视口坐标系上,所以我们又要再做一个场景坐标到视口坐标的转换。自此 top 计算出来了。

const startGlyphInfo = glyphInfos[range.start]
// 文本实体上的光标位置
const cursorPosInText = startGlyphInfo.position;
const textMatrix = textGraphics.getWorldTransform();
// 场景坐标
const top = applyMatrix(textMatrix, cursorPosInText);
// 画布坐标
const topInViewport = this.editor.toViewportPt(top.x, top.y);

bottom 位置同理,加上高度再进行同样的矩阵变换。

const bottom = applyMatrix(textMatrix, {
  x: cursorPosInText.x,
  y: cursorPosInText.y + contentHeight,
});
const bottomInViewport = this.editor.toViewportPt(bottom.x, bottom.y);

如果 range.start 和 range.end 不相等,则渲染为一个半透明的矩形,当然因为矩阵变换的缘故,也可能会变成一个平行四边形。

我们要计算这个平行四边形的 4 个点,前面我们已经算出 top 和 bottom 这两个点了,我们再计算一个 right,见下图。

计算过程也大同小异,right 对应 range.end 索引位置的 glyph。

let rightInViewport = null;

if (range.end !== range.start) {
  const endGlyphInfo = glyphInfos[range.end]
  const endPosInText = endGlyphInfo.position;
  const right = applyMatrix(textMatrix, endPosInText);
  rightInViewport = this.editor.toViewportPt(right.x, right.y);
}

top、bottom、right 这三个点,再基于平行四边形(矩形做了矩阵变换)的特征,可以算出最后一个点,然后就可以进行渲染了。

具体怎么渲染就不展开了,不同渲染库写法不一样。

输入法定位问题

下面我们看看,怎么通过键盘输入文本。

既然都做所见即所得了,看起来我们不需要用 input、textarea 这些 dom 元素了,直接监听 keydown 事件应该就好了。

但实际上它是有局限性的,它只能用在不需要输入法的场景,比如只输入英文。如果你用输入法输入中文,因为没有 focus 一个输入框中,所以不会有输入法的浮窗出现。

所以我们还是要 提供一个文本输入元素并让它保持 focus 状态

这里我选择用 input 元素,因为我的文本编辑首先还是比较简单的。

input 元素虽然必须要在,但让它看起来不在就行了

我们把它的不透明度设置为 0,然后 z-index 设置为 -1,宽度也改成 1px(保证 input 下的光标保持在 input 框中的起始位置)。

const defaultInputStyle = {
  opacity: 0,
  zIndex: '-1',
  width: '1px',

  margin: 0,
  padding: 0,
  border: 0,
  outline: 0,

  position: 'fixed',
}

fixed 定位

为了让输入法弹窗定位到正确的位置,我们需要 给 input 设置 fixed 定位

我们确保 input 的左下角对齐前面计算的那个 bottomInViewport 即可

具体计算为:left 为前面计算的那个 bottomInViewport 的 x,再加上 canvas 相对页面左测的偏移值;top 为 bottomInViewport 的 y 值减去文本的字体大小,再加上 canvas 相对页面顶部的偏移值。

const styles = {
  left: bottomInViewport.x + canvasOffsetX + 'px',
  top: bottomInViewport.y - inputDomHeight + canvasOffsetY + 'px',
  height: `${inputDomHeight}px`,
  fontSize: `${inputDomHeight}px`,
}
Object.assign(inputDom.style, styles);

这时候有的同学可能会问了,问我怎么不用 absolute 定位,相对 canvas 的容器元素。说实话我是用过的,然后发现一个 input 元素的特性。

就是如果一个 input 元素在一个 div 下,但是呢,它跑到 div 的显示区域外,看不到它。

当这个 input 是 focus 状态时,那浏览器会强行修改 div 的 offset 让 input 可以被看到,结果是突然 div 上出现了一大块空白区域,主体内容被挤不见了。

换成 fixed 就不会有这个问题,输入法弹窗会移动页面外,但不会影响页面的布局。

输入文本

当文本编辑被激活时,这个 input 会设置为 focus 状态。

此时我们监听 input 元素的 input 事件,将用户输入的内容更新到文本实体 textGraphics 上,并修正 range。

我们可以通过 input 事件对象的 isComposing 是否为 true 判断用户是否在使用输入法。

简单输入

首先是比较简单的场景,不输入中文的情况。

inputDom.addEventListener('input', (e) => {
    
  // ...
    
  // Not IME input, directly add to textGraphics
  if (!e.isComposing && e.data) {
    const { rangeLeft, rangeRight } = rangeManager.getSortedRange();

    const content = textGraphics.attrs.content;
    const newContent =
      sliceContent(content, 0, rangeLeft) +
      e.data +
      sliceContent(content, rangeRight);

    // 更新文本实体的 content 和 size
    TextEditor.updateTextContentAndResize(textGraphics, newContent);
    const dataLength = getContentLength(e.data);
    // 更新 range 的状态,往右边移动 e.date 的长度
    this.rangeManager.setRange({
      start: rangeLeft + dataLength,
      end: rangeLeft + dataLength,
    });
  }
}

e.isComposing 为 false 表示没有在使用输入法,然后 e.data 保存的是用户输入的内容。

需要注意,e.data 可能存在为 null 的情况,比如 backspace 删除字符,粘贴空内容,这种情况需要过滤掉。

我们在 content 字符串 range 区域的字符串丢弃,然后将 e.data 的字符串拼接进去,得到 newContent,并对文本实体 textGraphics 进行更新,最后更新 range 的状态,往右边移动 e.date 的长度。

因为 unicode 的存在,我们不能用字符串的 length 属性了,那都是骗人的,要改用 for...of 去实现一些字符串方法。

// 获取字符串的长度
const getContentLength = (content) => {
  let count = 0;
  for (const _ of content) {
    count++;
  }
  return count;
};

// 字符串截断
const sliceContent = (content, start, end) => {
  let res = '';
  let i = 0;
  for (const char of content) {
    if (end !== undefined && i >= end) {
      break;
    }
    if (i >= start) {
      res += char;
    }
    i++;
  }
  return res;
};

通过输入法输入

如果使用了输入法,情况会复杂一点。

这种场景下,e.isComposing 为 true,e.data 则是用户正在输入的内容。

比如我想输入 “你好”,通过拼音输入法进行完整的拼音输入,最后按下空格。这个过程中 input 事件会多次触发,e.data 依次为:

n
ni
ni h
ni ha
ni hao
你好

所以我们不能将每次 input 事件的 e.data 直接拼接到 content 上。

我们需要在 e.isComposing 第一次为 true 时,保存好 range 两边的字符串内容,以及 e.data 的内容。

之后就将开始时两边的字符串和 e.data 拼接即可。

inputDom.addEventListener('input', (e) => {
  let composingText = '';
  let leftContentWhenComposing = '';
  let rightContentWhenComposing = '';
    
  if (e.isComposing) {
    if (!composingText) {
      // 输入法第一次输入内容,保存好 range 两边的内容
      const { rangeLeft, rangeRight } = rangeManager.getSortedRange();
      const content = textGraphics.attrs.content;
      leftContentWhenComposing = sliceContent(content, 0, rangeLeft);
      rightContentWhenComposing = sliceContent(content, rangeRight);
    }
    composingText = e.data ?? '';
  } else {
    // 重置
    composingText = '';
    leftContentWhenComposing = '';
    rightContentWhenComposing = '';
  }
  
  // ...
  
  if (e.isComposing) {
    const newContent =
      leftContentWhenComposing + composingText + rightContentWhenComposing;
    
    TextEditor.updateTextContentAndResize(textGraphics, newContent);
   // 更新 range
    const newRangeStart =
      getContentLength(leftContentWhenComposing) +
      getContentLength(composingText);
    rangeManager.setRange({
      start: newRangeStart,
      end: newRangeStart,
    });
  }
})

各种快捷键行为

然后是监听 input 的 keydown 事件,实现各种编辑操作。

inputDom.addEventListener('keydown', (e) => {
  // ...
})

1、Esc,退出文本编辑模式。

if (e.key === 'Escape') {
  this.inactive();
}

2、左方向键,如果光标状态,range 左移动一位;如果选择状态,range 置为 rangeLeft。

如果还按住 Shift 键,只对 range.end 减 1。注意 range 的索引值不要越界。

if (e.key === 'ArrowLeft') {
  if (e.shiftKey) {
    this.rangeManager.moveRangeEnd(-1);
  } else {
    this.rangeManager.moveLeft();
  }
}

3、右方向键,同理。

4、Backspace,如果是光标状态,往左删掉一个字符,range 左移一位;如果是选中多个 字符状态,删掉这些字符,range 设置为 rangeLeft 。

5、Delete,类似 Backspace,但是是往右侧删除。

if (e.key === 'Backspace' || e.key === 'Delete') {
  let { rangeLeft, rangeRight } = this.rangeManager.getSortedRange();
  const isSelected = rangeLeft !== rangeRight;

  if (!isSelected) {
    rangeLeft = e.key === 'Backspace' ? rangeLeft - 1 : rangeLeft;
    rangeRight = e.key === 'Backspace' ? rangeRight : rangeRight + 1;
  }

  const content = textGraphics.attrs.content;
  const leftContent = sliceContent(content, 0, rangeLeft);
  const rightContent = sliceContent(content, rangeRight);
  const newContent = leftContent + rightContent;
  TextEditor.updateTextContentAndResize(textGraphics, newContent);

  if (isSelected) {
    rangeManager.setRange({
      start: rangeLeft,
      end: rangeLeft,
    });
  } else if (e.key === 'Backspace') {
    rangeManager.moveLeft();
  }
}

6、Command / Ctrl + A,全选,将 range 区间设置为 content 的完全的区间。

this.rangeManager.setRange({
  start: 0,
  end: this.textGraphics.getContentLength(),
});

7、Command / Ctrl + C,复制。将 range 区间的文本写入到剪贴板

8、Command / Ctrl + X,剪切。将 range 区间的文本写入到剪贴板,然后将 range 的内容丢弃。

鼠标选中

下面看看怎么通过鼠标来进行文本的选择。

我们需要绑定 canvas 元素的鼠标事件,这个我原本就封装好了,其实也就是 canvas 上的鼠标事件对象拿到视口坐标,通过矩阵转换成场景坐标。

点击鼠标时,我们拿到这个场景坐标,然后我们给这个场景坐标做文本实体矩阵的 逆矩阵运算,得到在文本实体的本地坐标

然后就是 glyph 数组的 x 和鼠标位置的位置,找到被点中的 glyph。

class TextGraphics {
  // ...

  getCursorIndex(point) {
    // 逆矩阵得到本地坐标
    point = applyInverseMatrix(this.attrs.transform, point);
    const glyphs = this.getGlyphs();

    // binary search, find the nearest but not greater than point.x glyph index
    let left = 0;
    let right = glyphs.length - 1;
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      const glyph = glyphs[mid];
      if (point.x < glyph.position.x) {
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    }
    if (left === 0) return 0;
    if (left >= glyphs.length) return glyphs.length - 1;

    if (
      glyphs[left].position.x - point.x >
      point.x - glyphs[right].position.x
    ) {
      return right;
    }
    return left;
  }
}

这里用了二分查找,效率很高。

找到 glyph 后,我们还要看一下鼠标位置靠近 glyph 的左半部分还是右半部分,设置为更靠近的一边的索引值。

然后将这个索引值设置为 range 即可。

const cursorIndex = textGraphics.getCursorIndex(mousePt);
this.rangeManager.setRange({
  start: cursorIndex,
  end: cursorIndex,
});

然后此时拖拽鼠标,我们使用同样的方式,计算出索引值,设置给 range.end。

结尾

文本编辑,可以看作是对一个个矩形块进行编排,我们计算好每个字形 glyph 的包围盒,编排成一行或多行的文字。

然后引入 range 的概念,用来表达目前光标在哪里,或哪些矩形块被选中。

最后再通过监听键盘事件和 mouse 事件更新 range,并通过 input事件获取用户输入内容,直接更新到文本图形上。

这个文本编辑器还是比较简单,但基本的核心已经具备,希望对你有帮助。

我是前端西瓜哥,关注我,学习更多图形编辑器知识。

相关推荐

MORROR ART:毫无音质可言,真的只是好看而已...

今天早上我在微博上发了一条短视频,内容是某款网红音箱正在放声歌唱——这玩意就是此前曾经在网上挺火的所谓“悬浮歌词音箱”。这款产品是我同事收到的礼品,但她嫌在家里放着没用,所以拿到公司里做我们的拍摄道具...

「JS优化篇」你的 if - else 代码肯定没我写的好

作者:小生方勤转发链接:https://mp.weixin.qq.com/s/JzOQ_OwAYoP5Ic1VBtCZNA前言最近部门在对以往的代码做一些优化,我在代码中看到一连串的if(){}el...

细聊微内核架构在前端的应用「干货」

作者:semlinker转发链接:https://mp.weixin.qq.com/s/ywc98dS4TVB4t3L2tIyk8g一、微内核架构简介1.1微内核的概念微内核架构(Microke...

ThreeJS 入门教程(一) 是选择桌面的固守还是云原生?

导读:最近我购置了一台新的电脑,硬盘空间只有1T。我很担心这个电脑还能用多久。性能限制或者空间的限制,都使得在未来3-5年内,这个电脑会被淘汰。但是,基于云APP的使用,老的电脑是足够了,而且,我们也...

推荐三款正则可视化工具「JS篇」(正则在线调试)

作者:代码先森转发链接:https://mp.weixin.qq.com/s/rw29yKBwti5sIsx2GKG9pw前言最近老王对可视化非常着迷。例如,算法可视化、正则可视化、Vue数据劫持可...

Javascript 多线程编程的前世今生

作者:jolamjiang腾讯技术工程转发链接:https://mp.weixin.qq.com/s/87C9GAFb0Y_i5iPbIL5Hzg为什么要多线程编程大家看到文章的标题《Javasc...

Pug 3.0.0正式发布,不再支持 Node.js 6/8

作者:李俊辰前端之巅转发链接:https://mp.weixin.qq.com/s/q-49Gf-SFijeu7d2MqztIQ前言近日,Pug3.0.0正式发布,Pug原名Jade,是由...

36个工作中常用的JavaScript函数片段「值得收藏」

作者:Eno_Yao转发链接:https://segmentfault.com/a/1190000022623676前言如果文章和笔记能带您一丝帮助或者启发,请不要吝啬你的赞和收藏,你的肯定是我前进的...

深入JavaScript教你内存泄漏如何防范

作者:大道至简转发链接:https://mp.weixin.qq.com/s/0w6aWwpR3MAJnmyLwDnAzA前言一般情况下,忽视内存管理不会对传统的网页产生显著的后果。这是因为,用户刷新...

由浅入深,66条JavaScript面试知识点(七)

作者:JakeZhang转发链接:https://juejin.im/post/5ef8377f6fb9a07e693a6061目录由浅入深,66条JavaScript面试知识点(一)由浅入深,66...

用STM32做了个电子秤,成本仅两位数,精度高!解析一下原理

俗话说得好!人在胖,秤在看!所以,我想DIY一个精度高的体重秤!并希望它不只能称体重:还能像这样称克重(可设置KG,G,最低可称100克)……这样一来,做甜品的时候,还能拿来应应急。保姆级教程,记录在...

前端开发需要了解常用7种JavaScript设计模式

作者|Deven译者|王强策划|小智转发链接:https://mp.weixin.qq.com/s/Lw4D7bfUSw_kPoJMD6W8gg前言JavaScript中的设计模式指的是...

毛姆的一个手法|王培军(毛姆作品简介)

鲁本斯画《海伦娜·芙尔曼肖像》钱锺书在《宋诗选注》文同小传中说:“具体的把当前风物比拟为某种画法或某某大画家的名作”,是“从文同正式起头”。如钱先生所举的:“峰峦李成似,涧谷范宽能”,“独坐水轩人不到...

欣赏 | 朝戈:我渴望找到直达心灵的永恒

朋友,通过艺术让我们共同感知世界的永恒与不朽。——朝戈橙色的人物117X71cm布面油画2003包与陈185cm×103cm2007年白色80cm×40cm2009年光布面油画-Light-Oilo...

Web页面如此耗电!到了某种程度,会是大损失

现在用户上网大多使用移动设备或者笔记本电脑。对这两者来说,电池寿命都很重要。在这篇文章里,我们将讨论影响电池寿命的因素,以及作为一个web开发者,我们如何让网页耗电更少,以便用户有更多时间来关注我们的...