Lição 27 - Encaixe e alinhamento
O encaixe é um recurso comum em aplicativos de edição gráfica. A ideia central é alinhar automaticamente os limites ou os pontos de ancoragem dos elementos às linhas mais próximas da grade de pixels, ou a outros gráficos, ao mover, desenhar ou escalar elementos. Nesta lição, vamos apresentar a implementação.
Encaixar na grade de pixels
Na Lição 5 - Desenhar grades, vimos como desenhar grades de linhas retas com eficiência. Em algumas interações de arraste, como mover e desenhar, encaixar na unidade mínima da grade garante que a posição ou a informação geométrica dos gráficos seja inteira. No Figma, esse recurso se chama "Snap to pixel grid" e pode ser ativado em "User Preferences".

Fonte: Snap to grid in Excalidraw.
export interface AppState {
snapToPixelGridEnabled: boolean;
snapToPixelGridSize: number;
}Para implementar esse recurso, primeiro calculamos as coordenadas dos pontos no sistema de coordenadas do mundo. Quando o usuário arrasta, escala ou desenha gráficos, obtemos as coordenadas atuais (como x, y) e arredondamos essas coordenadas para o inteiro mais próximo (ponto de pixel) ou para o espaçamento de grade indicado:
// Snap to custom grid (e.g. 10px)
function snapToGrid(value, gridSize = 10) {
return Math.round(value / gridSize) * gridSize;
}Depois, aplique essa função de processamento em todos os Systems que precisam calcular coordenadas de pontos no sistema de coordenadas do mundo (como Select e DrawRect):
let { x: sx, y: sy } = api.viewport2Canvas({
x: prevX,
y: prevY,
});
let { x: ex, y: ey } = api.viewport2Canvas({
x,
y,
});
const { snapToPixelGridEnabled, snapToPixelGridSize } = api.getAppState();
if (snapToPixelGridEnabled) {
sx = snapToGrid(sx, snapToPixelGridSize);
sy = snapToGrid(sy, snapToPixelGridSize);
ex = snapToGrid(ex, snapToPixelGridSize);
ey = snapToGrid(ey, snapToPixelGridSize);
}No exemplo abaixo, definimos snapToPixelGridSize como 10. Você pode ver o efeito arrastando, movendo e desenhando:
Encaixe no nível do objeto
A implementação do encaixe no Excalidraw se divide nestes passos principais:
- Verificar se a operação atual permite encaixe (isSnappingEnabled).
- Calcular todos os pontos e intervalos encaixáveis (getPointSnaps).
- Calcular em tempo real os deslocamentos de encaixe e as linhas-guia durante as operações de arraste e escala (snapDraggedElements / snapResizingElements).
- Passar snapLines para a camada de UI e renderizar as linhas-guia na tela (renderSnaps).
Abaixo, vamos implementar isso seguindo os passos acima.
O encaixe em objetos funciona de forma independente da grade de pixels. snapToObjectsDistance é um raio em pixels CSS, então o zoom não muda a distância de atração percebida. Durante o movimento, calcule o candidato a partir da geometria do pointer-down e do deslocamento bruto do ponteiro; aplique o encaixe só à posição exibida. Alimentar a posição já encaixada na amostra seguinte faria o objeto ser puxado de volta para a guia o tempo todo.
O movimento oferece suporte ao alinhamento de borda e centro da caixa delimitadora e a guias de intervalos iguais. O redimensionamento encaixa a borda ou o canto em movimento nas bordas e nos centros de outros objetos, preservando a borda oposta, as restrições de proporção e a escala centralizada. Em objetos rotacionados, a alça em movimento é alinhada em coordenadas de mundo. As referências excluem objetos ocultos ou fora da tela e os ancestrais e descendentes da seleção. Esses são os comportamentos implementados; a distribuição de intervalos no redimensionamento, a correspondência de tamanhos iguais e o encaixe durante o desenho ficam como trabalho seguinte, então isto ainda não equivale por completo ao Figma.
O encaixe está ativado
Adicionamos as seguintes opções de configuração aos ajustes do aplicativo, que também podem ser ativadas no “Menu de preferências”:
export interface AppState {
snapToObjectsEnabled: boolean;
}Disparado ao mover ou redimensionar formas selecionadas:

Obter pontos de encaixe
Os pontos de encaixe se dividem em duas categorias: formas selecionadas e as demais formas. Para uma ou mais formas selecionadas, os pontos de encaixe comuns incluem os quatro cantos e o centro da caixa delimitadora:
const { minX, minY, maxX, maxY } = api.getGeometryBounds(
elements.map((id) => api.getNodeById(id)),
);
const boundsWidth = maxX - minX;
const boundsHeight = maxY - minY;
return [
[minX, minY], // corners
[maxX, minY],
[minX, maxY],
[maxX, maxY],
[minX + boundsWidth / 2, minY + boundsHeight / 2], // center
] as [number, number][];Pensando no desempenho, devemos reduzir ao máximo quantas vezes detectamos os pontos de contato da forma selecionada em relação a todas as outras formas. Já tratamos de questões parecidas na Lição 8 - Usar indexação espacial, em que só recuperamos as formas dentro do viewport.
const unculledAndUnselected = api
.getNodes()
.map((node) => api.getEntity(node))
.filter((entity) => !entity.has(Culled) && !entity.has(Selected));Da mesma forma, calcule os pontos de referência dessas formas:
const referenceSnapPoints: [number, number][] = unculledAndUnselected
.map((entity) => getElementsCorners(api, [api.getNodeByEntity(entity).id]))
.flat();Em seguida, compare um a um os pontos de encaixe da forma selecionada com os das outras formas. Identifique o par de pontos mais próximo na horizontal ou na vertical e registre a diferença de distância como a distância de encaixe seguinte:
nearestSnapsX.push({
type: 'point',
points: [thisSnapPoint, otherSnapPoint],
offset: offsetX,
});
minOffset[0] = Math.abs(offsetX);Depois que todos os pontos de encaixe forem calculados, essa distância mínima pode ser usada como distância de encaixe. É importante notar que, nesta etapa, o gráfico ainda não se moveu de acordo com a distância de encaixe. Por isso, precisamos supor que o gráfico já chegou à posição final e fazer outra rodada de cálculos:
// 1st round
getPointSnaps();
const snapOffset: [number, number] = [
nearestSnapsX[0]?.offset ?? 0,
nearestSnapsY[0]?.offset ?? 0,
];
// Clear the min offset
minOffset[0] = 0;
minOffset[1] = 0;
nearestSnapsX.length = 0;
nearestSnapsY.length = 0;
const newDragOffset: [number, number] = [
round(dragOffset[0] + snapOffset[0]),
round(dragOffset[1] + snapOffset[1]),
];
// 2nd round, a new offset must be considered.
getPointSnaps(newDragOffset);
// Render snap linesObter encaixes de intervalo
Além da forma selecionada na tela, outras formas podem formar pares com intervalos entre elas. O diagrama no código do Excalidraw ilustra isso bem. Tome horizontalGap como exemplo:
// https://github.com/excalidraw/excalidraw/blob/f55ecb96cc8db9a2417d48cd8077833c3822d64e/packages/excalidraw/snapping.ts#L65C1-L81C3
export type Gap = {
// start side ↓ length
// ┌───────────┐◄───────────────►
// │ │-----------------┌───────────┐
// │ start │ ↑ │ │
// │ element │ overlap │ end │
// │ │ ↓ │ element │
// └───────────┘-----------------│ │
// └───────────┘
// ↑ end side
startBounds: Bounds;
endBounds: Bounds;
startSide: [GlobalPoint, GlobalPoint];
endSide: [GlobalPoint, GlobalPoint];
overlap: InclusiveRange;
length: number;
};Se a caixa delimitadora da forma selecionada não se sobrepõe ao intervalo, pule a detecção.
for (const gap of horizontalGaps) {
if (!rangesOverlap([minY, maxY], gap.overlap)) {
continue;
}
}Detecte, em sequência, o ponto central, a borda direita e a borda esquerda:
// center
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset[0]) {
}
// side right
if (Math.abs(sideOffsetRight) <= minOffset[0]) {
}
// side left
if (Math.abs(sideOffsetLeft) <= minOffset[0]) {
}Se a condição for atendida, registre. Durante o processo, continue registrando a distância mínima:
const snap: GapSnap = {
type: 'gap',
direction: 'center_horizontal',
gap,
offset: centerOffset,
};
nearestSnapsX.push(snap);Renderizar linhas de encaixe
Renderize as linhas de encaixe no contêiner SVG. Veja: Lição 26 - Seleção laço.
Os pontos de encaixe costumam ser representados por uma “cruz”, e as linhas que ligam esses pontos podem ser renderizadas com <line>.
renderSnapLines(
api: API,
snapLines: { type: string; points: [number, number][] }[],
) {
const { svgSVGElement } = this.selections.get(api.getCamera().__id);
this.clearSnapLines(api);
snapLines.forEach(({ type, points }) => {
if (type === 'points') {
const pointsInViewport = points.map((p) =>
api.canvas2Viewport({ x: p[0], y: p[1] }),
);
const line = createSVGElement('polyline') as SVGPolylineElement;
svgSVGElement.appendChild(line);
}
});
}Quanto à exibição das linhas de encaixe de intervalo, a documentação do Excalidraw é bem ilustrativa. A partir dela, podemos adicionar rótulos de texto com a distância abaixo das linhas auxiliares (direção horizontal) ou à direita delas (direção vertical), como o Figma faz:
// a horizontal gap snap line
// |–––––––||–––––––|
// ^ ^ ^ ^
// \ \ \ \
// (1) (2) (3) (4)
Mova ou redimensione o retângulo do meio abaixo. Use os controles independentes para comparar o encaixe em objetos e o encaixe na grade de pixels:
Move the middle rectangle, or drag a selection edge or corner. Move beyond the snap range to release; Shift preserves proportions and Alt resizes from the center.