嗯你没看错,这篇文章的主角还是那个编辑器,还是熟悉的 react-dnd
和 immutable-js
。现在这个编辑器已经可以替代目前线上的旧版编辑器了,而且功能更全。接下来我就说一说,为了让这个编辑器的用户体验更好,我都做了哪些事情——有技术方面的,也有设计方面的。
技术方面
拖拽时显示元素的详细内容
由于用的是 react-dnd
,之前的效果是默认效果——对元素 DOM 所占矩形区域的网页截图,这样就造成了一些神奇的现象。后来我用了一个临时的解决方案,就是使用 dragPreview
来为拖拽添加预览图,但是预览图很简单,就是生成一个与元素大小相同的矩形。这周我对 dragPreview
做了一些扩充。
首先,由于浏览器限制,dragPreview
本身必须是一个图片元素,因此考虑到便利性,我直接使用了 SVG,大概是这样子:
import previews from '~/utils/dragPreviews';
const { type, styles } = element;
const { width, height } = styles;
const img = new Image();
img.width = width;
img.height = height;
img.src = previews[type](element);
// 留意下面这行代码(1),之后会提到
img.onload = connectDragPreview;
然后在要导入的文件内部对 previews
进行了一波封装,保证每一项都是个返回 SVG Base64 DataURI 的函数:
export default Object.entries(previews).reduce((prev, [key, gen]) => ({
...prev,
[key]: element => 'data:image/svg+xml;base64,' + btoa(`
<svg xmlns="http://www.w3.org/2000/svg" width="${element.width}" height="${element.height}">
<!-- 拖拽时需要添加一个绿色的外边框 -->
<rect width="${element.width}" height="${element.height}" stroke-width="1" stroke="green" stroke-dasharray="3,2" fill="none" />
${gen(element)}
</svg>
`.replace(/\n\s+/g, ''))
}), {});
这样具体生成的函数就很简单了,直接针对元素的类型(text
、image
等)生成对应的 XML 即可。对于嵌套元素的情况,我在生成的时候传入了一个 recursive
参数,默认是 false
,当其为 true
时,给元素设置相对坐标,否则为 (0, 0)
。
一个比较特殊的情况是,假如 Image 元素的 src
是输入的网址,那么生成的 SVG 将会是 <image href="xxx" width="xxx" height="xxx" />
,这样刚才的代码(1)就会失效,因为 onload
只保证了 XML 本身加载成功,而不保证里面引用的外部资源加载成功。为了解决这个问题,我先 new Image()
,然后设置其 src
,onload
时使用 toDataURL
将其转换为 DataURI 并嵌入 SVG 中。但是对于不同域的资源,在 toDataURL
的时候会报错:Tainted canvases may not be exported
。还好网上有解决方案:<img crossOrigin="Anonymous" />
。因为这是异步,所以我将这个函数写成了 Promise
,然后一路 await
下来,还好不是太难看。
为了防止每次都重复加载(虽然大部分情况下是 304,但还是会慢),我给每个 src
做了个缓存。于是最终的效果如下:
撤销和重做
这个算是细节最多的东西了,虽然使用了 immutable-js
让其方便了许多。代码其实很简单:
function replaceCurrentState(actions, oldState, newState) {
actions.history.push(oldState);
return actions.current = newState;
}
// in reducer
if (action.type === UNDO) {
if (actions.history.length) {
actions.future.push(state);
actions.current = actions.history.pop();
}
return actions.current;
} else if (action.type === REDO) {
if (actions.future.length) {
actions.history.push(state);
actions.current = actions.future.pop();
}
return actions.current;
}
// for some action type
return replaceCurrentState(actions, state, newState);
但是麻烦就麻烦在,究竟要在哪些场景下才需要执行 replaceCurrentState
?并不是所有的 Reducer 都要执行一次,举个例子,只要按了方向键就会派发一个 MOVE_ELEMENT
,但如果此时没有选中元素,就不应该执行这个。没啥简单的办法,在 Reducer 中特判吧。
设计方面
合并将菜单栏与标题栏
由于是编辑器,画的东西是面单,经常会超出屏幕范围,因此绘图区域越大越好。之前的设计是最上面一个标题栏,下面是菜单栏,考虑到菜单栏的项目有限,我就将菜单栏挪到了标题的右边,这样两个栏的高度能减少一半。
物件列表显示的文字
听从了产品的建议,当元素类型为 text
时,左边的物件列表中不再显示其 ID,而是显示文字内容,方便在元素多的时候定位到具体元素。
绘图区的物件样式修改
由于后续的需求(当绘图区出现 {{xxx}}
类似的变量时,显示时将其替换为一个假的替代值),用户无法直观得出某个元素究竟是什么变量,因此当鼠标移过时,在左上角加了个小标签来显示,同时如果元素被锁定,则在标签的文字后面加了一个 Emoji 的锁,鼠标指针变为“不可用”。这个修改完全可以使用 CSS 的 :hover
来实现,因此如果元素间有嵌套关系,父元素和子元素的标签均可以被显示出来。
除此以外,为了方便对齐,我听从了产品的建议,给每个元素添加了一个外边框。如果有时间做第二期的话,我会考虑写一些优化过的代码来实现对各个元素边界和中心的吸附。
END.
版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。