前端canvas项目实战——在线图文编辑器(七):加粗、斜体、下划线、删除线(下)

前言

上一篇博文中,我们实现了为文字添加和修改加粗、斜体、下划线、删除线

这篇博文是《前端canvas项目实战——在线图文编辑器》付费专栏系列博文的第七篇——加粗、斜体、下划线、删除线(下),主要的内容有:

  1. 在上一篇实现加粗、斜体、下划线和删除线等功能时遇到bug及其解决方案。
  2. 在上一篇的实现中,我们发现了可以对数据进行优化的地方,降低数据存储和前后端传输的数据大小,减少浪费。

博主自己是一个有代码洁癖的人,写代码和文章的时候喜欢精益求精。因此,在写代码的时候遇到bug就一定会修复,遇到可以优化时间复杂度或者空间复杂度的地方,就一定会去优化。这样,一份我自认为比较接近完美的代码和一篇去繁就简、条理清晰的博文就一并出现在了你的眼前。

最近我经常思考,一个「初学者」除了想要学会如何去实现一个需求,更重要的是学习到「老码客」遇到问题是怎么思考和分解的。基于此,从本篇博文开始,我新增了这样一个小节「Bug点和优化点」,旨在分享“我实现过程中遇到了什么样的bug,怎么解决的?”和“有什么点优化了会更好,是怎么优化的?”这两部分的思考和解决问题的方法。

如有需要,你可以:

  • 点击这里,阅读序文《前端canvas项目实战——在线图文编辑器:序》
  • 点击这里,返回上一篇《前端canvas项目实战——在线图文编辑器(六):加粗、斜体、下划线、删除线(上)》
  • 点击这里,前往下一篇《前端canvas项目实战——在线图文编辑器(八):复制、删除、锁定、层叠顺序》

一、实现过程中发现的4个Bug

在实现的过程中,我会对页面进行丰富的测试,尽可能的避免代码中UI或者逻辑上的异常,只把验证过正确的东西讲给大家。在这个过程中一共发现了4个Bug,3个是我自身的代码逻辑考虑不周导致的,1个是fabric.js框架本身的问题。

Bug点1:_shouldUpdateTheWholeTextbox方法

如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。

在前文中,我们使用这个方法判断当前用户选中的Textbox的状态,进而判断这个时候是要更新整个Textbox的属性还是只想更新自己选中的部分文字的属性。

const _shouldUpdateTheWholeTextbox = (object, key) => {
  ...
  // Bug点1:容易忘记考虑编辑状态时,选中了全部文字
  if (!isEditing || isSelectAll) {
    return true;
  }
  return key === "lineHeight";
};

这个方法在上篇博文中有贴过,但当时判断条件只写了if(!isEditing),即只要用户不处于编辑状态就应该更新整个Textbox。这里产生了一个bug:有一种情况,用户在编辑状态,使用Ctrl + A组合键,选中了全部的文字,此时也应该更新整个Textbox的属性。

经过改动后,if (!isEditing || isSelectAll)的判断逻辑,就兼容了这种之前未考虑到的状态。

Bug点2:_updateFontPropertyForWholeObject方法

如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。

在上文中,这个方法用于更新整个Textbox的属性值。

const _updateFontPropertyForWholeObject = (key, newValue) => {
  ...
  // Bug点2:由于部分文字的样式优先级高于整个文本框的样式,先清除整个文本框的对应属性
  activeObject.removeStyle(key);
  ...
};

先看看出现bug的场景:

这个Textbox共有「只有中间的几个字有下划线」这几个字,我们事先为「中间的几个字」局部设置了下划线。当我们对整个文本框设置了下划线,再取消时,bug出现了,「中间的几个字」的下划线没被取消掉。

经过分析,我们需要从数据层面入手。fabric.Textbox的实现中,上面这个文本框的属性字典形如:

{
  "type": "textbox",
  ..., 
  "underline": false, 
  "styles": [{
    "startIndex": "2", 
    "endIndex": "7", 
    "style": {"underline": true}
  }]
}
  • "underline": false 表示文本框整体不设置下划线
  • styles: [...] 表示文本框中从第2到第7个字符设置了下划线

产生bug的原因是:fabric的设定中,styles里设置的局部属性的优先级高于underlinestrikethrough这些对象的整体属性。

也就是说,想要全局的underline对所有文字生效,就要先去除优先级更高的styles数组中的局部设置。 因此,我们在这里加了activeObject.removeStyle(key);。因为示例是在设置下划线,我们将key="underline"带入代码,即activeObject.removeStyle("underline");

再来看修复了Bug之后,先设置全局下划线,再取消时,文本框的属性经历了怎样的变化

// 0. 原本的数据字典
{
  "type": "textbox",
  ..., 
  "underline": false, 
  "styles": [{
    "startIndex": "2", 
    "endIndex": "7", 
    "style": {"underline": true}
  }]
}

// 1. 先通过removeStyle方法删掉所有underline的定义
{
  "type": "textbox",
  ..., 
  "styles": []
}

// 2. 再为全局设置上underline
{
  "type": "textbox",
  ..., 
  "underline": true, 
  "styles": []
}


// 3. 全局取消underline
{
  "type": "textbox",
  ..., 
  "underline": false, 
  "styles": []
}

最后看看bug修复后的效果

可以看到,对全局设置/取消属性不再受到已经设置的局部属性的影响。

Bug点3:_updateFontPropertyForSelection方法

如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。

在上文中,这个方法用于更新用户选中的部分文字的属性值。

const _updateFontPropertyForSelection = (key, newValue) => {
  ...
  // Bug点3:编辑态时,如果没有选中任何文字,不做任何处理
  if (selectionEnd === selectionStart) {
    return;
  }
  ...
};

这个问题比较简单,只是一开始没有想到,测试时报错了。当文本框处于编辑态,用户也没有选中任何文字时,点击任何的按钮都不应该做任何更新数据的操作,让方法直接返回。

Bug点4:选择部分文字加大字号,会导致文字超出选择框

先看看这个bug的表现:

可以看到,当部分文字被设置了很大的字号时,本应被「折行」到下一行的文字仍在原行,导致文字超出了选择框。经过搜索,我们发现fabric.js的Github仓库已经有人提了相似的issue

经过探索之后发现,是fabric.Textbox.prototype._wrapLine这个私有方法存在问题。这个方法是用来处理文字「折行」逻辑的,篇幅有限,我们只展示关注的代码行:

_wrapLine: function (_line, lineIndex, desiredWidth, reservedSpace) {
  var splitByGrapheme = this.splitByGrapheme,
      words = splitByGrapheme ? fabric.util.string.graphemeSplit(_line) : _line.split(this._wordJoiners),
      wordWidth = 0,
      additionalSpace = this._getWidthOfCharSpacing();
  ...
  for (var i = 0; i < words.length; i++) {
    // if using splitByGrapheme words are already in graphemes.
    word = splitByGrapheme ? words[i] : fabric.util.string.graphemeSplit(words[i]);
    
    // 1. 计算行宽度,逐个加上字符宽度
    lineWidth += infixWidth + wordWidth - additionalSpace;
    
    // 2. 如果行宽度大于选择框宽度,就折行
    if (lineWidth > desiredWidth && !lineJustStarted) {
      graphemeLines.push(line);
      line = [];
      lineWidth = wordWidth;
      lineJustStarted = true;
    } else {
      lineWidth += additionalSpace;
    }
    
    // 3. 依赖offset值计算字符的宽度
    wordWidth = this._measureWord(word, lineIndex, offset);
    ...
    // 4. offset在每次循环都自增1
    offset++;
    ...
  }
  ...
};

在这个方法中,有个for循环逐个计算字符的宽度来判断一行是否超过了选择框的宽度,如果超过了,就进行「折行」,然后继续累加,判断下一行,直到遍历完每个字符。

根据fabric.js的实现:splitByGrapheme是一个boolean型的属性,表示文本框是否把每个字符当做一个整体。

  • splitByGraphemefalse(默认值)时,文本框把每个单词当做一个整体,用「空格」间隔,适用于英文等语言。
  • splitByGraphemetrue时,文本框把每个字符当做一个整体,没有间隔。用于兼容中文、日文等语言。

问题就出在offset++这里,没有做判断,统一让offset自增1 而实际上:

  • 当遍历完一个英文单词,后面有一个「空格」,这个时候应该offset++
  • 当遍历完一个中文字符,后面没有「空格」,不应该让offset自增。

那么问题就找到了,我们为offset++这一行加上判断条件:

_wrapLine: function (_line, lineIndex, desiredWidth, reservedSpace) {
    ...
    // 只有splitByGrapheme是false时,offset才自增1
    if (!splitByGrapheme) {
      offset++;
    }
    ...
};

现在看看修复后的效果


二、 1个优化点——降低空间复杂度

minifySelectionStyles方法

如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。

对这个方法的调用也出现在上文中的_updateFontPropertyForSelection方法中。

const _updateFontPropertyForSelection = (key, newValue) => {
  ...
  // 优化点1:删除冗余的数据
  activeObject.minifySelectionStyles();
};

先看看这个优化点涉及的操作:

这个过程中,文本框的属性字典变化如下:

// 0. 初始状态
{
  "type": "textbox",
  ..., 
  "strikethrough": false, 
  "styles": []
}

// 1. 为选中的部分文字设置删除线
{
  "type": "textbox",
  ..., 
  "strikethrough": false, 
  "styles": [{
    "startIndex": "2", 
    "endIndex": "7", 
    "style": {"strikethrough": true}
  }]
}

// 2. 再取消选中的部分文字的删除线
{
  "type": "textbox",
  ..., 
  "strikethrough": false, 
  "styles": [{
    "startIndex": "2", 
    "endIndex": "7", 
    "style": {"strikethrough": false}
  }]
}

两次点击按钮之后,预期的数据字典应该恢复成一开始的样子,但实际的情况是:

  • 全局的"strikethrough": false本来就对所有文字都生效,意为全部文字都不设置删除线。
  • styles中还有冗余的局部属性设置,表示第2到第7个文字不设置下划线。

因此,一个合理的解决方案就是在每次为局部的文字设置属性后,尝试查找并删除styles中冗余的属性设置。

这样做并不是因为我们吹毛求疵。一个Textbox中冗余几十个字符确实不算很大的存储开销,但时可以想象,如果我们制作一份简历,就会有几十个文本框,如果每个都冗余几十个字符,一共就会多存几千个字符,这对传输和存储都会造成很大的浪费。

所以,对于这种fabric本应考虑在内,但是并没有的方法,我们直接修改fabric.Textbox的原型,为其添加一个方法:

/**
 * 针对 Textbox 的 styles 属性,删除冗余的数据
 * 数据结构形如:{"0":{"20":{"fontSize":36},"21":{"fontSize":36,"fontWeight":800}}},3层字典结构
 * 当 Textbox 整体的 fontSize 为 36时,styles 中的第 20和 21位单独设置fontSize 为 36就是冗余的,需要删除
 * 删除后的 styles 为 {"0":{"21":{"fontWeight":800}}}
 */
fabric.Textbox.prototype.minifySelectionStyles = function () {
  // 遍历第一层 key
  for (let entryKey in this.styles) {
    // 第一层字典的 value,形如{"20":{"fontSize":36},"21":{"fontSize":36,"fontWeight":800}}
    let stylesMapOfEntry = this.styles[entryKey];
    // 遍历第二层 key
    for (let indexKey in stylesMapOfEntry) {
      // 第二层字典的 value,形如{"fontSize":36,"fontWeight":800}
      let stylesMapOfIndex = stylesMapOfEntry[indexKey];
      // 遍历第三层 key
      for (let PropertyKey in stylesMapOfIndex) {
        // 如果第三层字典的 value 和 Textbox 对应属性的值相等
        if (stylesMapOfIndex[PropertyKey] === this[PropertyKey]) {
          // 则为冗余属性,从字典中删除
          delete this.styles[entryKey][indexKey][PropertyKey];
        }
      }
      // 如果经过删除,第三层字典为空的{},例如"20":{}
      stylesMapOfIndex = this.styles[entryKey][indexKey];
      // 则删除第二层字典中的"20"
      if (Object.keys(stylesMapOfIndex).length === 0) {
        delete this.styles[entryKey][indexKey];
      }
    }
    // 如果经过删除,第二层字典为空的{},例如"0":{}
    stylesMapOfEntry = this.styles[entryKey];
    // 则删除第一层字典中的"0"
    if (Object.keys(stylesMapOfEntry).length === 0) {
      delete this.styles[entryKey];
    }
  }
};

代码的逻辑很清晰,且添加了逐行的注释,不再过多解释。

唯一要说明的是——styles的结构在内存中和序列化之后并不相同。 所以实现过程中需要在脑内经常转换这两种数据结构。

  • 在内存中按行和索引,二维结构:
    • 形如{"0":{"20":{"fontSize":36},"21":{"fontSize":36,"fontWeight":800}}}
    • 表示第0行的第20位设置了fontSize=36,第0行的第21位设置了fontSizefontWeight两个属性。
  • 通过JSON.stringify(textbox)序列化之后,为了便于传输和存储,一维结构:
    • 形如[{"startIndex": "20", "endIndex": "21", "style": {"fontSize": 36}}, {"startIndex": "21", "endIndex": "21", "style": {"fontWeight":800}}]
    • 表示从第20位到第21位,都设置了fontSize=36,第21位还设置了fontWeight=800

后记

单纯的实现需求没有太大的意义。在这过程中会遇到的一个一个功能上、性能上的问题,我们如何思考,如何解决,这些都是难得的实践经验。

在我看来,这篇博文中的内容,远远比上一篇博文中的基础实现来的重要。因此单独拆成一篇博文,花了很大篇幅和精力来讲解。希望其中「分析问题的方法」和「解决问题的思路」能给你带来收获!

如有需要,你可以:

  • 点击这里,阅读序文《前端canvas项目实战——在线图文编辑器:序》
  • 点击这里,返回上一篇《前端canvas项目实战——在线图文编辑器(六):加粗、斜体、下划线、删除线(上)》
  • 点击这里,前往下一篇《前端canvas项目实战——在线图文编辑器(八):复制、删除、锁定、层叠顺序》
  • 15
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

IMplementist

你的鼓励,是我继续写文章的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值