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

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

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

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

suika 图形编辑器 github 地址:

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

线上体验:

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

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

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

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

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

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

但它有如下缺陷:

  • 无法保持文本图形原来所在的层级,只能提升到顶层。这样就不能所见即所得的看文本图形和它上方图形效果叠加的效果,比如没法实时观察并调整毛玻璃滤镜下文字渲染效果。
  • 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) => {
  // ...
})

Esc,退出文本编辑模式。

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

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

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

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

右方向键,同理。

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

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

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

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

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

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事件获取用户输入内容,直接更新到文本图形上。

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

责任编辑:姜华 来源: 前端西瓜哥
相关推荐

2020-10-10 11:01:40

后端程序员技术

2024-04-15 11:24:32

库存跟踪技术NFC蓝牙

2022-03-07 10:22:07

DevOps开发工具

2017-04-25 09:29:42

入职场技术选择

2016-01-22 10:53:09

前端APP融合

2020-06-02 10:00:33

GitHub 技术开发

2018-10-17 11:05:00

Java开发代码

2009-12-31 09:57:43

2023-02-13 08:21:25

微服务架构微前端

2018-09-07 14:53:30

MarTechAdTechROI

2017-03-27 17:53:45

Linux

2023-03-10 15:03:37

Web 应用程序API开发

2023-03-16 18:04:00

APIWeb 应用程序开发

2017-08-07 18:45:51

前端JavaScript技术栈

2017-07-26 13:51:19

前端JavaScriptTypeScript

2020-07-02 10:43:38

程序员技术设计

2021-10-28 10:08:54

数据库安全网络安全网络攻击

2021-01-11 11:32:18

程序员前端跳槽

2020-11-10 15:01:22

人工智能生物识别安全

2011-03-04 14:25:00

PTN
点赞
收藏

51CTO技术栈公众号