一. 涂色功能的实现

  • 用广度优先遍历算法
  • 实现过程出现栈上溢,用数组代替递归

虽然最后衡量再三不想加了,但还是让我学到了 flood fill 这个词组,意思是画图工具的颜色填充。

给我帮助的链接

SO的回答

二. tutorial 添加

之前不知道准确表达这件事,导致卡了一圈。

还好后来找到了,就是 intro.js

关键代码

let tutorial = [
        {
          element: '#background',
          intro: '临摹工具,可临摹本地图片'
        },
        {
          element: '#openFile',
          intro: '编辑工具,可编辑本地图片'
        },
        {
          element: '.sizePad',
          intro: '调节画笔粗细,也可在任意地方通过滑轮操作'
        },
        {
          element: '.colorPad',
          intro: '双击调色盘定义常用颜色,单击调色盘快捷使用'
        },
        {
            element: '#pen',
            intro: '按 Ctrl + Z 可撤销画笔或橡皮操作'
        }
    ]

function startTutorial() {
    let steps = tutorial
    introJs()
    .setOptions({steps})
    .setOption("nextLabel", "下一个")
    .setOption("prevLabel", "上一个")
    .setOption("skipLabel", "跳过")
    .setOption("doneLabel", "完成")
    .setOption("overlayOpacity", 0)
    /* 下面一行需要注意 */
    .setOption("highlightClass", "intro-highlight")
    .start()
}

let hinted = localStorage.getItem('hinted')
if(!hinted) {
    localStorage.setItem('hinted', true)
    startTutorial()
}

刚上手时,发现高亮的地方不透明。我想要的是透明效果,所以查了 stackoverflow,找到以下解法

/*
 * @filename: intro-theme.css
 */

.intro-highlight {
    background: transparent;
}

.intro-highlight:before {
    opacity: 0;
    content: '';
    position: fixed;
    width: inherit;
    height: inherit;
    border-radius: 0.5em;
    box-shadow: 0 0 0 1000em rgba(0,0,0, .7);
    opacity: 1;
}

.intro-highlight:after {
    content: '';
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    position: fixed;
    z-index: 1000;
}

这样就 ok 了

给我帮助的链接

intro.js 文档

bootcdn

透明效果

三. 撤销功能的实现

思想是用一个 command 数组 push 用户每次操作,需要撤销时将画布清空,按照记录直到最后一步之前重新执行一次。

关键代码

/*
 * @filename: recorder.js
 */

let commandStack = []
let undoing = false

// 实现撤销功能的函数
function undo() {
    commandStack.pop()
    undoing = true
    redraw()
    undoing = false
}

// fn, args 分别是用户单步操作调用的 api 函数名和传入参数
function addCommand(fn, args) {
    // 注意这里应该用深拷贝,否则会踩对象引用的坑
    args = JSON.parse(JSON.stringify([...args]))
    // 这里用 undoing,如果没有它会使数组无限增长而导致 redraw 2-5 行无限循环
    !undoing && commandStack.push({fn, args})
}

function redraw() {
    clearCanvas()
    for(let i=0; i<commandStack.length; i++) {
        let {fn, args} = commandStack[i]
        fn.apply(null, args)
    }
}

同时,在用户每个单步操作调用的 api 上楔入 addCommand 函数

function drawLine([p1, p2, p3, p4], penWidth, penColor) {
    // 记录用户动作
    addCommand(drawLine, arguments)
    
    // 原代码
}

function eraseCanvas({x, y}, size) {
    // 记录用户动作
    addCommand(wipeCanvas, arguments)
    
    // 原代码
}

撤销操作的本质是:先写一个重现操作,画布清空后将以前的操作重做一遍,只不过这次在最后一步之前停手

其实写到这里我想到还有两个优化点。

  • undo 应该做成一个防抖函数。按住 ctrl + Z,触发画板分别重做以前的 9999 步,9998 步,9997, 9996 步操作,不如过段时间再触发,画板只需重做以前 9996 步操作
  • 而且这里可以进一步实现 redo。给定一个下标 i,每次 undo 的时候下标左移,redo 的时候下标右移。改为从 0 开始实现到下标 i 就可以了。这样一来放东西时不能用 push,改用 command[i+1] = {fn, args}
给我帮助的链接

老外关于这事的讨论