R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

为了用户体验而做的事情

注意:本文发布于 2168 天前,文章中的一些内容可能已经过时。

0

嗯你没看错,这篇文章的主角还是那个编辑器,还是熟悉的 react-dndimmutable-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, ''))
}), {});

这样具体生成的函数就很简单了,直接针对元素的类型(textimage 等)生成对应的 XML 即可。对于嵌套元素的情况,我在生成的时候传入了一个 recursive 参数,默认是 false,当其为 true 时,给元素设置相对坐标,否则为 (0, 0)

一个比较特殊的情况是,假如 Image 元素的 src 是输入的网址,那么生成的 SVG 将会是 <image href="xxx" width="xxx" height="xxx" />,这样刚才的代码(1)就会失效,因为 onload 只保证了 XML 本身加载成功,而不保证里面引用的外部资源加载成功。为了解决这个问题,我先 new Image(),然后设置其 srconload 时使用 toDataURL 将其转换为 DataURI 并嵌入 SVG 中。但是对于不同域的资源,在 toDataURL 的时候会报错:Tainted canvases may not be exported。还好网上有解决方案:<img crossOrigin="Anonymous" />。因为这是异步,所以我将这个函数写成了 Promise,然后一路 await 下来,还好不是太难看。

为了防止每次都重复加载(虽然大部分情况下是 304,但还是会慢),我给每个 src 做了个缓存。于是最终的效果如下:

1

撤销和重做

这个算是细节最多的东西了,虽然使用了 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.

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3050 天