Lição 14 - Modo da tela e interface auxiliar
Antes, implementamos alguns componentes de interface da tela com Web Components, incluindo o zoom da câmera e o download de imagens. Nesta lição, vamos expor mais capacidades da tela por meio de componentes e implementar algumas propriedades novas de desenho:
- Implementar as propriedades de desenho
zIndexesizeAttenuation - Mover, girar e escalar a tela no modo mão
- Selecionar, arrastar e mover formas no modo de seleção, e exibir o painel de propriedades da forma
Antes de implementar os modos da tela, precisamos de um trabalho preparatório para dar suporte às propriedades de desenho zIndex e sizeAttenuation.
Implementar zIndex
A camada de máscara exibida depois de selecionar uma forma precisa aparecer acima de todas as formas, o que envolve a ordem de exibição, controlada por zIndex:
mask.zIndex = 999;No z-index do CSS, o valor só faz sentido dentro do mesmo Stacking context. Por exemplo, na figura abaixo, embora DIV #4 tenha um z-index maior que DIV #1, ele ainda é renderizado mais abaixo por estar no contexto de DIV #3:

Como a ordenação é uma operação que consome bastante desempenho, adicionamos a propriedade sortDirtyFlag a Shape, definida como true sempre que zIndex muda:
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;
}
}
}
}Também precisamos considerar isso em appendChild e removeChild:
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;
}
}Em seguida, em cada tick do loop de renderização, fazemos uma verificação de sujeira e ordenamos se necessário. Também não queremos alterar children diretamente, e sim usar sorted para guardar o resultado da ordenação; afinal, z-index deve afetar apenas a ordem de renderização, não a ordem real no grafo de cena:
traverse(this.#root, (shape) => {
if (shape.sortDirtyFlag) {
shape.sorted = shape.children.slice().sort(sortByZIndex);
shape.sortDirtyFlag = false;
}
// Omit rendering each shape.
});A implementação de sortByZIndex é a seguinte: se zIndex estiver definido, ordena em ordem decrescente; caso contrário, mantém a ordem original no nó pai. Isso também mostra por que não alteramos children: ele preserva a base de ordenação padrão:
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;
}circle1Zindex = Inputs.range([-10, 10], {
label: 'z-index of red circle',
value: 0,
step: 1,
});circle2Zindex = Inputs.range([-10, 10], {
label: 'z-index of green circle',
value: 0,
step: 1,
});$icCanvas3 = call(() => {
return document.createElement('ic-canvas');
});circle1 = call(() => {
const { Circle } = Core;
return new Circle({
cx: 100,
cy: 100,
r: 50,
fill: 'red',
});
});circle2 = call(() => {
const { Circle } = Core;
return new Circle({
cx: 150,
cy: 150,
r: 50,
fill: 'green',
});
});call(() => {
circle1.zIndex = circle1Zindex;
circle2.zIndex = circle2Zindex;
});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);
});
});Ao exportar para SVG, z-index não pode ser mapeado diretamente para propriedades do elemento, porque o SVG é renderizado pela ordem em que os elementos aparecem no documento; veja: How to use z-index in svg elements?
No SVG, o z-index é definido pela ordem em que o elemento aparece no documento.
Por isso, precisamos fazer um trabalho extra de ordenação na exportação, e a implementação da ordenação de SerializedNode é quase a mesma de Shape, que não é mostrada aqui:
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;
}Implementar sizeAttenuation
Mencionamos antes [sizeAttenuation of polylines]. Ao exibir uma camada de máscara e pontos de âncora depois de selecionar uma forma, não queremos que eles mudem de tamanho com o zoom da câmera. Por exemplo, no excalidraw, interfaces auxiliares como alças de arraste (handle) ajustam a largura da linha de acordo com o nível de zoom, usando a API Canvas2D:
const renderTransformHandles = (): void => {
context.save();
context.lineWidth = 1 / appState.zoom.value;
};É claro que já passamos u_ZoomScale para o Shader, para o ajuste.
Compressão de vértices
Para bits de flag como sizeAttenuation, que só têm 0 e 1, dá para usar a técnica de compressão de vértices. Em resumo, tentamos usar vec4 para armazenar esses dados de vértice e aplicar certas técnicas de compressão, o que reduz o tempo de transferência de dados do lado da CPU para o lado da GPU e economiza bastante memória da GPU. Além disso, há um limite superior para o número de atributos suportados pelo OpenGL. O esquema de compressão também é bem simples: comprimir em JS no lado da CPU e descomprimir no vertex shader. Ele é aplicado tanto no mapbox quanto no Cesium; veja: Graphics Tech in Cesium - Vertex Compression. Vamos ver abaixo como comprimir dois valores em um único float:
Em GLSL, float é um número de ponto flutuante de precisão simples Scalars, isto é, o formato IEEE-754 Single-precision floating-point format

Podemos comprimir sizeAttenuation e type em um único float, em que sizeAttenuation ocupa 1 bit e type ocupa 23 bits.
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]; Ao decodificar no shader, também é necessário manter a mesma ordem da codificação:
#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;Depois de obter sizeAttenuation, use-o no vertex shader para ajustar as coordenadas dos vértices, tomando a implementação SDF como exemplo:
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);Exportar SVG
Como essa propriedade está relacionada ao zoom da câmera, é necessário um processamento extra ao exportar SVG, que oferece vector-effect:
non-scaling-size->sizeAttenuation, mas essas opções não têm implementações e são consideradas em risco.non-scaling-stroke->strokeAttenuation
Modo da tela
Telas infinitas costumam oferecer muitos modos, como modo de seleção, modo mão, modo caneta e outros. Você pode consultar Excalidraw ToolType e rnote.
A mesma ação de interação corresponde a operações diferentes em modos diferentes. Por exemplo, arrastar na tela corresponde à operação de seleção no modo de seleção; no modo mão, arrasta a tela inteira; no modo caneta, é o desenho livre de traços de caneta.
Primeiro, vamos adicionar os modos de seleção e mão à tela, que podem ser ampliados no futuro:
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;
}
}Vamos implementar um novo componente de interface para alternar entre esses modos.
Barra de seleção de modo
Usando Dynamic classes and styles do Lit, conseguimos um efeito parecido com clsx (se você já usou tailwindcss no seu projeto, vai reconhecer na hora), gerenciando className, por exemplo gerando-o com base em condições. Aqui usamos isso para implementar o estilo destacado do modo selecionado:
@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>
`;
}
}Além disso, para reduzir a quantidade de código de template, usamos Built-in directives - map do Lit. O efeito é o seguinte:
call(() => {
const $canvas = document.createElement('ic-canvas');
$canvas.style.width = '100%';
$canvas.style.height = '100px';
return $canvas;
});Modo mão
Como o nome sugere, neste modo os usuários só podem fazer pan, girar e dar zoom na tela inteira. Já implementamos CameraControlPlugin; agora vamos combiná-lo com o modo da tela. No modo mão, o comportamento continua igual ao original, isto é, mover ou girar a tela. A diferença é que, no início do arraste e durante o processo, o estilo do mouse muda para grab e grabbing:
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);
}
}
});
}
}Fazer pan da tela com a roda
Eu sempre confundi, por engano, wheel com o comportamento de rolagem ou com o evento scroll. Esta é a explicação da MDN para Element: wheel event:
Um evento wheel não dispara necessariamente um evento scroll. Por exemplo, o elemento pode não ser rolável. Ações de zoom com a roda ou o trackpad também disparam eventos wheel.
Durante o uso do Figma e do Excalidraw, percebi que, além de arrastar, usar a roda também completa rapidamente a operação de pan da tela. No Excalidraw, também há suporte a segurar Space para arrastar em outros modos da tela; veja: handleCanvasPanUsingWheelOrSpaceDrag. Portanto, vamos modificar primeiro a lógica original de zoom:
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;
});Vale observar que a distância percorrida a cada vez precisa considerar o nível de zoom atual da câmera, e essa distância deve ser menor quando se está ampliado.
Dar zoom na tela com a roda
É claro que o comportamento de zoom ainda precisa ser mantido, disparado quando Command ou Control está pressionado. Se você ativou a função pinch to zoom no trackpad do Mac, o evento wheel disparado carrega automaticamente ctrlKey; veja: Catching Mac trackpad zoom:

Desse modo, distinguimos com facilidade os comportamentos diferentes que correspondem ao evento wheel nos cenários de zoom e de pan:
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;
}
});Vale mencionar que o Excalidraw também permite segurar Shift para rolar a tela na horizontal. Porém, já atribuímos a operação de rotação a esse comportamento da tela, então ele não será implementado aqui.
Modo de seleção
No modo de seleção, os usuários podem selecionar formas na tela clicando. No estado selecionado, uma interface auxiliar é sobreposta à forma original e, em geral, consiste em uma camada de máscara e vários pontos de âncora. Arrastar na camada de máscara move a forma, e arrastar nos pontos de âncora muda o tamanho da forma em várias direções. Também vamos adicionar um ponto de âncora fora da forma, na parte de cima, para rotação.

Clicar para selecionar formas
Vamos implementar o plugin Selector abaixo, e a interface a seguir também será exposta ao Canvas:
export class Selector implements Plugin {
selectShape(shape: Shape): void;
deselectShape(shape: Shape): void;
}Depois de escutar o evento click neste plugin, precisamos tratar as seguintes situações:
- Clicar em uma forma exibe a interface no estado selecionado. Se outras formas já estiverem selecionadas, cancele a seleção primeiro.
- Clicar em uma área em branco da tela cancela a forma selecionada no momento.
- Segurar Shift entra no modo de seleção múltipla.
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);$icCanvas = call(() => {
return document.createElement('ic-canvas');
});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 = '200px';
$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);
// setTimeout(() => {
canvas.selectShape(ellipse);
// }, 1000);
});
$icCanvas.addEventListener('ic-frame', (e) => {
stats.update();
});
});Arrastar para mover formas
O HTML oferece suporte nativo a arrastar; é claro que também podemos implementar isso com eventos de mais baixo nível, como pointermove / up / down. Veja: Drag'n'Drop with mouse events. Nossa implementação também se apoia nas ideias desse artigo. No evento dragstart, registramos o deslocamento do mouse na tela. Observe que aqui usamos o sistema de coordenadas screen, porque o zoom da câmera precisa ser considerado:
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;
}
});No evento drag, ajustamos a posição da camada de máscara de acordo com o deslocamento. Usar a propriedade position não exige modificar a definição do caminho da máscara; na renderização de baixo nível, só u_ModelMatrix muda:
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);
}
});No evento dragend, sincronizamos a posição da camada de máscara com a forma; nesse momento, o caminho da máscara é modificado:
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`;
}
});Exibir o painel de propriedades
Quando uma forma é selecionada, queremos exibir o painel de propriedades correspondente à forma; veja Drawer - Contained to an Element, que não será detalhado aqui. Tomando a propriedade stroke como exemplo, fazemos uma vinculação bidirecional, escutando a forma selecionada no momento e, depois que o usuário a modifica manualmente, sincronizando o novo valor com a forma:
<sl-color-picker
hoist
size="small"
value="${this.shape?.stroke}"
@sl-input="${this.handleStrokeChange}"
opacity
></sl-color-picker>É preciso observar que, para cores, queremos separar a transparência, então usamos o método getFormattedValue de sl-color-picker para obter o valor da cor e, em seguida, a biblioteca d3-color para analisá-lo, atribuindo os valores a stroke e strokeOpacity respectivamente:
const strokeAndOpacity = (e.target as any).getFormattedValue('rgba') as string;
const { rgb, opacity } = rgbaToRgbAndOpacity(strokeAndOpacity); // with d3-colorÉ claro que há muitas funções a implementar no modo de seleção, como mesclar seleções em grupos, girar formas e outras, que vamos continuar aprimorando nas lições seguintes. Além disso, no modo de desenho, queremos oferecer suporte a desenhar retângulos, linhas, polígonos e outros, o que também implementaremos nas lições seguintes.