Skip to content

课程 14 - 画布模式与辅助 UI

之前我们使用 Web Components 实现了一些包括相机缩放、图片下载在内的 画布 UI 组件。在本节课中我们将通过组件把更多画布能力暴露出来,另外还将实现一些新的绘图属性:

  • 实现 zIndexsizeAttenuation 绘图属性
  • 在手型模式下移动、旋转、缩放画布
  • 在选择模式下单选、多选、移动图形
  • 在绘制模式下向画布中添加图形

在实现画布模式之前,我们需要做一些准备工作,支持 zIndexsizeAttenuation 这两个绘图属性。

实现 zIndex

选中图形后展示的蒙层需要展示在所有图形之上,这就涉及到展示次序的用法了,可以通过 zIndex 控制:

ts
mask.zIndex = 999;

在 CSS z-index 中,数值的大小只有在同一个 Stacking context 下才有意义。例如下图中虽然 DIV #4z-index 大于 DIV #1,但由于它处在 DIV #3 的上下文中,在渲染次序上还是在更下面:

Understanding z-index

由于排序是一个非常消耗性能的操作,因此我们为 Shape 增加一个 sortDirtyFlag 属性,每当 zIndex 改变时就将父节点的该属性置为 true

ts
class Sortable {
    get zIndex() {
        return this.#zIndex;
    }
    set zIndex(zIndex: number) {
        if (this.#zIndex !== zIndex) {
            this.#zIndex = zIndex;
            this.renderDirtyFlag = true;
            if (this.parent) {
                this.parent.sortDirtyFlag = true; 
            }
        }
    }
}

appendChildremoveChild 时也需要考虑:

ts
class Shapable {
    appendChild(child: Shape) {
        if (child.parent) {
            child.parent.removeChild(child);
        }

        child.parent = this;
        child.transform._parentID = -1;
        this.children.push(child);

        if (!isUndefined(child.zIndex)) {
            this.sortDirtyFlag = true; 
        }

        return child;
    }
}

然后在渲染循环的每个 tick 中进行脏检查,如有需要才进行排序。另外我们不希望直接改变 children,而是使用 sorted 存储排序结果,毕竟 z-index 只应当影响渲染次序而非场景图中的实际顺序:

ts
traverse(this.#root, (shape) => {
    if (shape.sortDirtyFlag) {
        shape.sorted = shape.children.slice().sort(sortByZIndex); 
        shape.sortDirtyFlag = false; 
    }
    // Omit rendering each shape.
});

sortByZIndex 的实现如下,如果设置了 zIndex 就按降序排列,否则就保持在父节点中的原始顺序,这里也能看出我们不改变 children 的意义,它保留了默认情况下的排序依据:

ts
export function sortByZIndex(a: Shape, b: Shape) {
    const zIndex1 = a.zIndex ?? 0;
    const zIndex2 = b.zIndex ?? 0;
    if (zIndex1 === zIndex2) {
        const parent = a.parent;
        if (parent) {
            const children = parent.children || [];
            return children.indexOf(a) - children.indexOf(b);
        }
    }
    return zIndex1 - zIndex2;
}
js
circle1Zindex = Inputs.range([-10, 10], {
    label: 'z-index of red circle',
    value: 0,
    step: 1,
});
js
circle2Zindex = Inputs.range([-10, 10], {
    label: 'z-index of green circle',
    value: 0,
    step: 1,
});
js
$icCanvas3 = call(() => {
    return document.createElement('ic-canvas');
});
js
circle1 = call(() => {
    const { Circle } = Core;
    return new Circle({
        cx: 100,
        cy: 100,
        r: 50,
        fill: 'red',
    });
});
js
circle2 = call(() => {
    const { Circle } = Core;
    return new Circle({
        cx: 150,
        cy: 150,
        r: 50,
        fill: 'green',
    });
});
js
call(() => {
    circle1.zIndex = circle1Zindex;
    circle2.zIndex = circle2Zindex;
});
js
call(() => {
    $icCanvas3.setAttribute('modes', '[]');

    $icCanvas3.style.width = '100%';
    $icCanvas3.style.height = '250px';

    $icCanvas3.addEventListener('ic-ready', (e) => {
        const canvas = e.detail;
        canvas.appendChild(circle1);
        canvas.appendChild(circle2);
    });
});

当导出为 SVG 时,不能直接将 z-index 映射到元素属性,原因是 SVG 是按元素出现在文档中的顺序渲染的,详见:How to use z-index in svg elements?

In SVG, z-index is defined by the order the element appears in the document.

因此在导出时我们需要进行额外的排序工作,对 SerializedNode 的排序实现几乎和 Shape 相同,这里就不再展示了:

ts
export function toSVGElement(node: SerializedNode, doc?: Document) {
    // Omit handling other attributes.
    [...children]
        .sort(sortByZIndex) 
        .map((child) => toSVGElement(child, doc))
        .forEach((child) => {
            $g.appendChild(child);
        });

    return $g;
}

实现 sizeAttenuation

之前我们提到过 折线的 sizeAttenuation,在选中图形后展示蒙层和锚点时,我们不希望它们随相机缩放改变大小。例如在 excalidraw 中,辅助类 UI 例如拖拽把手(handle)的线宽就会根据缩放等级调整,这里使用的是 Canvas2D API:

ts
const renderTransformHandles = (): void => {
    context.save();
    context.lineWidth = 1 / appState.zoom.value; 
};

当然我们已经将 u_ZoomScale 传入了 Shader 中进行调整。

顶点压缩

类似 sizeAttenuation 这样只有 0 和 1 的标志位,可以采用顶点压缩技术。简而言之,我们尽量利用 vec4 存储这些顶点数据并采用一定的压缩技术,可以减少 CPU 侧向 GPU 侧传递数据的时间并节省大量 GPU 内存。另外,OpenGL 支持的 attribute 数目也是有上限的。压缩方案也很简单,在 CPU 侧 JS 中压缩,在 vertex shader 中解压。在 mapbox 和 Cesium 中都有应用,详见:Graphics Tech in Cesium - Vertex Compression。下面我们来看如何将两个数值压缩到一个 float 中:

GLSL 中 float 是单精度浮点数 Scalars,即 IEEE-754 Single-precision floating-point format

binary32 bits layout

我们可以将 sizeAttenuationtype 压缩到一个 float 中,其中 sizeAttenuation 占 1 位,type 占 23 位。

ts
const LEFT_SHIFT23 = 8388608.0;
const compressed = (sizeAttenuation ? 1 : 0) * LEFT_SHIFT23 + type;

const u_Opacity = [opacity, fillOpacity, strokeOpacity, type]; 
const u_Opacity = [opacity, fillOpacity, strokeOpacity, compressed]; 

在 shader 中 decode 时也要注意和 encode 顺序保持一致:

glsl
#define SHIFT_RIGHT23 1.0 / 8388608.0
#define SHIFT_LEFT23 8388608.0

// unpack data(sizeAttenuation(1-bit), type(23-bit))
float compressed = a_Opacity;

// sizeAttenuation(1-bit)
float sizeAttenuation = floor(compressed * SHIFT_RIGHT23);
compressed -= sizeAttenuation * SHIFT_LEFT23;

// type(23-bit)
float type = compressed;

得到 sizeAttenuation 后,在 vertex shader 中使用它调整顶点坐标,以 SDF 的实现为例:

glsl
float scale = 1.0;
if (sizeAttenuation > 0.5) {
    scale = 1.0 / u_ZoomScale;
}
gl_Position = vec4((u_ProjectionMatrix
    * u_ViewMatrix
    * model
    * vec3(position + v_FragCoord, 1)).xy, zIndex, 1);
    * vec3(position + v_FragCoord * scale, 1)).xy, zIndex, 1);

导出 SVG

由于该属性与相机缩放相关,因此在导出 SVG 时需要额外处理,暂不支持。

画布模式

无限画布通常都支持很多模式,例如选择模式、手型模式、记号笔模式等等,可以参考 Excalidraw ToolTypernote

而不同模式下同样的交互动作对应不同的操作。例如选择模式下,在画布上拖拽对应框选操作;在手型模式下会拖拽整个画布;记号笔模式下则是自由绘制笔迹。

首先让我们为画布增加选择和手型模式,未来可以继续扩展:

ts
export enum CanvasMode {
    SELECT,
    HAND,
    DRAW_RECT,
}

class Canvas {
    #mode: CanvasMode = CanvasMode.HAND;
    get mode() {
        return this.#mode;
    }
    set mode(mode: CanvasMode) {
        this.#mode = mode;
    }
}

下面让我们来实现一个新的 UI 组件在这些模式间切换。

模式选择工具条

使用 Lit 提供的 Dynamic classes and styles,我们可以实现类似 clsx 的效果(如果你在项目中使用过 tailwindcss 一定不会陌生),对 className 进行管理,例如根据条件生成。这里我们用来实现选中模式下的高亮样式:

ts
@customElement('ic-mode-toolbar')
export class ModeToolbar extends LitElement {
    render() {
        const items = [
            { name: CanvasMode.HAND, label: 'Move', icon: 'arrows-move' },
            { name: CanvasMode.SELECT, label: 'Select', icon: 'cursor' },
            {
                name: CanvasMode.DRAW_RECT,
                label: 'Draw rectangle',
                icon: 'sqaure',
            },
        ];
        return html`
            <sl-button-group label="Zoom toolbar">
                ${map(items, ({ name, label, icon }) => {
                    const classes = { active: this.mode === name }; 
                    return html`<sl-tooltip content=${label}>
                        <sl-icon-button
                            class=${classMap(classes)}
                            name=${icon}
                            label=${label}
                            @click="${() => this.changeCanvasMode(name)}"
                        ></sl-icon-button>
                    </sl-tooltip>`;
                })}
            </sl-button-group>
        `;
    }
}

另外,为了减少模版代码量,我们使用了 Lit 提供的 Built-in directives - map。效果如下:

js
call(() => {
    const $canvas = document.createElement('ic-canvas');
    $canvas.style.width = '100%';
    $canvas.style.height = '100px';
    return $canvas;
});

手型模式

顾名思义,在该模式下用户只能对画布整体进行平移、旋转和缩放操作。之前我们已经实现了 CameraControlPlugin,现在让我们与画布模式结合一下,在手型模式下和原来行为一致,即移动或者旋转画布。只是在拖拽开始时以及过程中将鼠标样式修改为 grabgrabbing

ts
export class CameraControl implements Plugin {
    apply(context: PluginContext) {
        root.addEventListener('drag', (e: FederatedPointerEvent) => {
            const mode = getCanvasMode();
            if (mode === CanvasMode.HAND) {
                setCursor('grabbing'); 

                if (rotate) {
                    rotateCamera(e);
                } else {
                    moveCamera(e);
                }
            }
        });
    }
}

通过 wheel 移动画布

之前我一直错误地将 wheel 和滚动行为或者说 scroll 事件搞混。以下是 MDN 对于 Element: wheel event 的说明:

A wheel event doesn't necessarily dispatch a scroll event. For example, the element may be unscrollable at all. Zooming actions using the wheel or trackpad also fire wheel events.

在使用 Figma 和 Excalidraw 的过程中,我发现除了拖拽,使用 wheel 也能快捷地完成画布平移操作。在 Excalidraw 中还支持在其他画布模式下按住 Space 拖拽,详见:handleCanvasPanUsingWheelOrSpaceDrag。因此我们先修改下原本的缩放逻辑:

ts
root.addEventListener('wheel', (e: FederatedWheelEvent) => {
    e.preventDefault();

    // zoomByClientPoint(
    //     { x: e.nativeEvent.clientX, y: e.nativeEvent.clientY },
    //     e.deltaY,
    // );
    camera.x += e.deltaX / camera.zoom; 
    camera.y += e.deltaY / camera.zoom; 
});

值得注意的是每次移动的距离需要考虑相机当前的缩放等级,在放大时每次应当移动更小的距离。

通过 wheel 缩放画布

当然缩放行为依旧需要保留,当按下 Command 或者 Control 时触发。如果你开启了 Mac 触控板的 pinch to zoom 功能,触发的 wheel 事件会自动带上 ctrlKey,详见:Catching Mac trackpad zoom

zoom in mac trackpad

这样我们就能很轻松地区分 wheel 事件在缩放和平移场景下对应的不同行为了:

ts
root.addEventListener('wheel', (e: FederatedWheelEvent) => {
    e.preventDefault();

    if (e.metaKey || e.ctrlKey) {
        zoomByClientPoint(
            { x: e.nativeEvent.clientX, y: e.nativeEvent.clientY },
            e.deltaY,
        );
    } else {
        camera.x += e.deltaX / camera.zoom;
        camera.y += e.deltaY / camera.zoom;
    }
});

值得一提的是,Excalidraw 还支持了按住 Shift 进行水平滚动画布。但此前我们已经为画布的该行为分配了旋转操作了,这里就不再实现了。

选择模式

在选择模式下,用户可以通过点击选中画布中的图形。在选中状态下,原图形上会覆盖一个辅助 UI,它通常由蒙层和若干锚点组成。在蒙层上拖拽可以移动图形,在锚点上拖拽可以沿各方向改变图形大小,我们还将在顶部图形之外增加一个锚点用于旋转。

Anchor positioning diagram with physical properties

点击选择图形

下面我们来实现 Selector 插件,以下接口也会暴露给 Canvas:

ts
export class Selector implements Plugin {
    selectShape(shape: Shape): void;
    deselectShape(shape: Shape): void;
}

在该插件监听 click 事件后,我们需要处理以下几种情况:

  • 点击图形展示选中状态的 UI。如果之前已经选中过其他图形,先取消选中
  • 点击画布空白处,取消当前选中的图形
  • 按住 Shift 进入多选模式
ts
const handleClick = (e: FederatedPointerEvent) => {
    const mode = getCanvasMode();
    if (mode !== CanvasMode.SELECT) {
        return;
    }

    const selected = e.target as Shape;

    if (selected === root) {
        if (!e.shiftKey) {
            this.deselectAllShapes();
            this.#selected = [];
        }
    } else if (selected.selectable) {
        if (!e.shiftKey) {
            this.deselectAllShapes();
        }
        this.selectShape(selected);
    } else if (e.shiftKey) {
        // Multi select
    }
};
root.addEventListener('click', handleClick);
js
$icCanvas = call(() => {
    return document.createElement('ic-canvas');
});
js
call(() => {
    const { Canvas, CanvasMode, RoughEllipse } = Core;

    const stats = new Stats();
    stats.showPanel(0);
    const $stats = stats.dom;
    $stats.style.position = 'absolute';
    $stats.style.left = '0px';
    $stats.style.top = '0px';

    // $icCanvas.setAttribute('zoom', '200');
    $icCanvas.setAttribute('mode', CanvasMode.SELECT);
    $icCanvas.style.width = '100%';
    $icCanvas.style.height = '500px';

    $icCanvas.parentElement.style.position = 'relative';
    $icCanvas.parentElement.appendChild($stats);

    $icCanvas.addEventListener('ic-ready', (e) => {
        const canvas = e.detail;

        const ellipse = new RoughEllipse({
            cx: 200,
            cy: 100,
            rx: 50,
            ry: 50,
            fill: 'black',
            strokeWidth: 2,
            stroke: 'red',
            fillStyle: 'zigzag',
        });
        canvas.appendChild(ellipse);

        canvas.selectShape(ellipse);
    });

    $icCanvas.addEventListener('ic-frame', (e) => {
        stats.update();
    });
});

多选

按住 Shift 可以进行多选。

拖拽移动图形

HTML 原生是支持拖拽的,当然我们使用更底层的事件例如 pointermove / up / down 也是可以实现的,详见:Drag'n'Drop with mouse events,我们的实现也借鉴了这篇文章的思路。在 dragstart 事件中记录下鼠标在画布上的偏移量,注意这里使用的是 screen 坐标系,因为要考虑到相机缩放的情况:

ts
let shiftX = 0;
let shiftY = 0;
this.addEventListener('dragstart', (e: FederatedPointerEvent) => {
    const target = e.target as Shape;
    if (target === this.mask) {
        shiftX = e.screen.x;
        shiftY = e.screen.y;
    }
});

drag 事件中根据该偏移量调整蒙层位置,使用 position 属性不必修改 mask 的路径定义,反映到底层渲染中只有 u_ModelMatrix 会发生改变:

ts
const moveAt = (canvasX: number, canvasY: number) => {
    const { x, y } = this.mask.position;
    const dx = canvasX - shiftX - x;
    const dy = canvasY - shiftY - y;

    this.mask.position.x += dx;
    this.mask.position.y += dy;
};

this.addEventListener('drag', (e: FederatedPointerEvent) => {
    const target = e.target as Shape;
    const { x, y } = e.screen;

    if (target === this.mask) {
        moveAt(x, y);
    }
});

dragend 事件中,将蒙层位置同步到图形上,此时才会修改蒙层的路径:

ts
this.addEventListener('dragend', (e: FederatedEvent) => {
    const target = e.target as Shape;
    if (target === this.mask) {
        this.tlAnchor.cx += this.mask.position.x;
        this.tlAnchor.cy += this.mask.position.y;

        const { cx: tlCx, cy: tlCy } = this.tlAnchor;

        this.mask.position.x = 0;
        this.mask.position.y = 0;
        this.mask.d = `M${tlCx} ${tlCy}L${trCx} ${trCy}L${brCx} ${brCy}L${blCx} ${blCy}Z`;
    }
});

展示属性面板

Drawer - Contained to an Element

合并选中成组

绘制模式

扩展阅读

Released under the MIT License.