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

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

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

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

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

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

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

相关推荐

如何设计一个优秀的电子商务产品详情页

加入人人都是产品经理【起点学院】产品经理实战训练营,BAT产品总监手把手带你学产品电子商务网站的产品详情页面无疑是设计师和开发人员关注的最重要的网页之一。产品详情页面是客户作出“加入购物车”决定的页面...

怎么在JS中使用Ajax进行异步请求?

大家好,今天我来分享一项JavaScript的实战技巧,即如何在JS中使用Ajax进行异步请求,让你的网页速度瞬间提升。Ajax是一种在不刷新整个网页的情况下与服务器进行数据交互的技术,可以实现异步加...

中小企业如何组建,管理团队_中小企业应当如何开展组织结构设计变革

前言写了太多关于产品的东西觉得应该换换口味.从码农到架构师,从前端到平面再到UI、UE,最后走向了产品这条不归路,其实以前一直再给你们讲.产品经理跟项目经理区别没有特别大,两个岗位之间有很...

前端监控 SDK 开发分享_前端监控系统 开源

一、前言随着前端的发展和被重视,慢慢的行业内对于前端监控系统的重视程度也在增加。这里不对为什么需要监控再做解释。那我们先直接说说需求。对于中小型公司来说,可以直接使用三方的监控,比如自己搭建一套免费的...

Ajax 会被 fetch 取代吗?Axios 怎么办?

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!今天给大家带来的主题是ajax、fetch...

前端面试题《AJAX》_前端面试ajax考点汇总

1.什么是ajax?ajax作用是什么?AJAX=异步JavaScript和XML。AJAX是一种用于创建快速动态网页的技术。通过在后台与服务器进行少量数据交换,AJAX可以使网页实...

Ajax 详细介绍_ajax

1、ajax是什么?asynchronousjavascriptandxml:异步的javascript和xml。ajax是用来改善用户体验的一种技术,其本质是利用浏览器内置的一个特殊的...

6款可替代dreamweaver的工具_替代powerdesigner的工具

dreamweaver对一个web前端工作者来说,再熟悉不过了,像我07年接触web前端开发就是用的dreamweaver,一直用到现在,身边的朋友有跟我推荐过各种更好用的可替代dreamweaver...

我敢保证,全网没有再比这更详细的Java知识点总结了,送你啊

接下来你看到的将是全网最详细的Java知识点总结,全文分为三大部分:Java基础、Java框架、Java+云数据小编将为大家仔细讲解每大部分里面的详细知识点,别眨眼,从小白到大佬、零基础到精通,你绝...

福斯《死侍》发布新剧照 &quot;小贱贱&quot;韦德被改造前造型曝光

时光网讯福斯出品的科幻片《死侍》今天发布新剧照,其中一张是较为罕见的死侍在被改造之前的剧照,其余两张剧照都是死侍在执行任务中的状态。据外媒推测,片方此时发布剧照,预计是为了给不久之后影片发布首款正式预...

2021年超详细的java学习路线总结—纯干货分享

本文整理了java开发的学习路线和相关的学习资源,非常适合零基础入门java的同学,希望大家在学习的时候,能够节省时间。纯干货,良心推荐!第一阶段:Java基础重点知识点:数据类型、核心语法、面向对象...

不用海淘,真黑五来到你身边:亚马逊15件热卖爆款推荐!

Fujifilm富士instaxMini8小黄人拍立得相机(黄色/蓝色)扫二维码进入购物页面黑五是入手一个轻巧可爱的拍立得相机的好时机,此款是mini8的小黄人特别版,除了颜色涂装成小黄人...

2025 年 Python 爬虫四大前沿技术:从异步到 AI

作为互联网大厂的后端Python爬虫开发,你是否也曾遇到过这些痛点:面对海量目标URL,单线程爬虫爬取一周还没完成任务;动态渲染的SPA页面,requests库返回的全是空白代码;好不容易...

最贱超级英雄《死侍》来了!_死侍超燃

死侍Deadpool(2016)导演:蒂姆·米勒编剧:略特·里斯/保罗·沃尼克主演:瑞恩·雷诺兹/莫蕾娜·巴卡林/吉娜·卡拉诺/艾德·斯克林/T·J·米勒类型:动作/...

停止javascript的ajax请求,取消axios请求,取消reactfetch请求

一、Ajax原生里可以通过XMLHttpRequest对象上的abort方法来中断ajax。注意abort方法不能阻止向服务器发送请求,只能停止当前ajax请求。停止javascript的ajax请求...