Lição 21 - Transformer
Em Lição 14, apresentamos brevemente o "modo de seleção" no modo da tela. Nesse modo, depois de selecionar uma forma, uma camada de operação é sobreposta a ela, e a forma pode ser movida ao arrastar. Nesta lição, vamos oferecer mais recursos de edição de formas, incluindo redimensionamento e rotação.
No Konva, a camada de operação sobre as formas selecionadas se chama Transformer e traz os exemplos a seguir:
Também escolhemos o nome Transformer. Ele se parece muito com a AABB da forma. Na prática, é uma OBB (oriented bounding box, caixa delimitadora orientada): um retângulo com ângulo de rotação nas coordenadas de mundo.
Serializar a matriz de transformação e as informações de dimensão
No Figma, a matriz de transformação local e as informações de dimensão das formas são as seguintes. Para formas 2D, a matriz de transformação mat3 pode ser decomposta em translação, escala e rotação. X/Y correspondem à translação, e a escala será apresentada na seção espelhamento. fig-file-parser
Por isso, modificamos a estrutura SerializedNode para que ela descreva o maior número possível de formas, e removemos alguns atributos de posição, como cx/cy do Circle, já que cx/cy pode ser calculado a partir de x/y e width/height.
export interface TransformAttributes {
// Transform
x: number;
y: number;
rotation: number;
scaleX: number;
scaleY: number;
// Dimension
width: number;
height: number;
}<circle cx="100" cy="100" r="50" /> é serializado assim, usando ellipse para representá-lo e permitir um redimensionamento mais flexível no futuro:
call(() => {
const { createSVGElement, svgElementsToSerializedNodes } = ECS;
const $circle = createSVGElement('circle');
$circle.setAttribute('cx', '100');
$circle.setAttribute('cy', '100');
$circle.setAttribute('r', '50');
const nodes = svgElementsToSerializedNodes([$circle]);
return nodes[0];
});Em formas como Polyline e Path, definidas pelos atributos point e d, esses atributos não podem ser removidos. É preciso calcular a AABB delas e recalcular esses atributos. Tome <polyline points="50,50 100,100, 100,50" /> como exemplo:
call(() => {
const { createSVGElement, svgElementsToSerializedNodes } = ECS;
const $polyline = createSVGElement('polyline');
$polyline.setAttribute('points', '50,50 100,100, 100,50');
const nodes = svgElementsToSerializedNodes([$polyline]);
return nodes[0];
});Pontos de âncora
Os pontos de âncora do Transformer se dividem em duas categorias, redimensionamento e rotação, com duas combinações comuns de quantidade.
Uma é a do Excalidraw e do Konva: 8 pontos de âncora ao redor do perímetro para redimensionar, mais um ponto de âncora independente para rotação:

A outra é a do tldraw e do Figma, com 4 pontos de âncora. Ao se aproximar desses 4 pontos pelo lado de fora, a interação vira rotação, e o redimensionamento horizontal e vertical é feito arrastando as quatro bordas:

Escolhemos esta solução, mais enxuta: uma máscara Rect como nó pai e quatro nós filhos Circle como pontos de âncora:
const mask = this.commands.spawn(
new UI(UIType.TRANSFORMER_MASK),
new Transform(),
new Renderable(),
new Rect(), // Using Rect component
);
const tlAnchor = this.createAnchor(0, 0, AnchorName.TOP_LEFT); // Using Circle component
const trAnchor = this.createAnchor(width, 0, AnchorName.TOP_RIGHT);
const blAnchor = this.createAnchor(0, height, AnchorName.BOTTOM_LEFT);
const brAnchor = this.createAnchor(width, height, AnchorName.BOTTOM_RIGHT);
this.commands
.entity(mask)
.appendChild(this.commands.entity(tlAnchor))
.appendChild(this.commands.entity(trAnchor))
.appendChild(this.commands.entity(blAnchor))
.appendChild(this.commands.entity(brAnchor));Sistema de coordenadas do Transformer
Em Lição 6 - Coordenadas, implementamos a conversão entre os sistemas de coordenadas Viewport, Canvas e Client. Aqui entra um sistema novo: o sistema de coordenadas local da máscara. Quando a máscara tem transformações, como rotação, os pontos de âncora, que são nós filhos, precisam saber a própria posição no sistema de coordenadas de mundo. Adicionamos este conjunto de métodos de conversão:
transformer2Canvas(camera: Entity, point: IPointData) {
const { mask } = camera.read(Transformable);
const matrix = Mat3.toGLMat3(mask.read(GlobalTransform).matrix);
const [x, y] = vec2.transformMat3(
vec2.create(),
[point.x, point.y],
matrix,
);
return {
x,
y,
};
}
canvas2Transformer(camera: Entity, point: IPointData) {}Exibir o cursor CSS
Ao passar o mouse sobre um ponto de âncora, o estilo do mouse precisa mostrar de forma intuitiva a função correspondente. No lado web, isso é feito alterando o estilo do <canvas>. O CSS cursor padrão tem poucos ícones: não há um ícone com a semântica de rotação e, no Excalidraw e no Konva, só dá para usar grab no lugar. Há, sim, 8 ícones de redimensionamento, mas as formas podem estar rotacionadas. Quando o ângulo não é múltiplo inteiro de 45, mesmo calculando e escolhendo o ícone adequado como o Konva faz, não dá para representá-lo com precisão:
function getCursor(anchorName: string, rad: number) {
rad += DEG_TO_RAD * (ANGLES[anchorName] || 0);
const angle = (((RAD_TO_DEG * rad) % 360) + 360) % 360;
if (inRange(angle, 315 + 22.5, 360) || inRange(angle, 0, 22.5)) {
return 'ns-resize';
}
}Por isso, usamos estilos de mouse personalizados, ajustados de forma dinâmica conforme o ângulo de rotação. How can I rotate a css cursor mostra um caminho com SVG, e o tldraw acrescenta a lógica de cálculo dinâmico do ângulo; veja useCursor. Tome o ponto de âncora superior direito como exemplo:

Aplique a transformação de rotação ao ícone SVG para obter o valor do cursor nesse momento:
// SVG angles are in degrees; both reflections are in the object's local axes.
const sx = scaleX < 0 ? -1 : 1;
const sy = scaleY < 0 ? -1 : 1;
const r = (rotation - cameraRotation) * RAD_TO_DEG;
const transform = `translate(16 16) rotate(${r}) scale(${sx} ${sy}) rotate(${tr}) translate(-16 -16)`;Mantenha o sinal de cada eixo de escala. O SVG aplica as transformações da direita para a esquerda: oriente o desenho para a alça, reflita nos eixos locais do objeto e, em seguida, rotacione para o viewport. O determinante, sozinho, não distingue reflexão horizontal, vertical e dupla, e refletir depois da rotação aponta o cursor para a direção errada.
Quando o mouse se aproxima do ponto de âncora, a interação deixa de ser rotação e passa a ser redimensionamento:

Como disparar o picking ao se aproximar do ponto de âncora de longe?
Expandir a área interativa
A primeira ideia foi permitir que as formas expandam ou até personalizem a área interativa. O Pixi.js, por exemplo, oferece hitArea. Também podemos adicionar esse campo ao componente Renderable:
export class Renderable {
@field({ type: Type.object, default: null }) declare hitArea: Circle | Rect;
}Leve essa propriedade em conta ao calcular os limites no sistema ComputeBounds, para definir uma área de detecção circular maior do que o ponto de âncora:
if (hitArea instanceof Circle) {
renderBounds = Circle.getRenderBounds(hitArea);
}Essa abordagem tem um problema evidente: mesmo que a área interativa seja 5 vezes maior do que o ponto de âncora, quando a câmera dá zoom ainda é preciso passar o mouse sobre o ponto de âncora para disparar o picking. Por isso, o picking precisa acontecer fora do sistema de coordenadas de mundo da tela.
Picking em coordenadas do viewport
A detecção de picking precisa ocorrer no sistema de coordenadas do viewport, para ignorar o zoom da câmera.
Primeiro, calcule as posições dos quatro pontos de âncora no sistema de coordenadas de mundo da tela, em vez de usar diretamente o cx/cy deles. Caso contrário, o resultado erra quando o próprio Transformer está rotacionado (vamos ver isso em breve):
hitTest(api: API, { x, y }: IPointData) {
const { tlAnchor, trAnchor, blAnchor, brAnchor } = camera.read(Transformable);
const { x: tlX, y: tlY } = api.canvas2Viewport(
// Need to consider Transformer's own transformation, such as rotation
api.transformer2Canvas(camera, {
x: tlAnchor.read(Circle).cx,
y: tlAnchor.read(Circle).cy,
}),
);
// Omit calculation of other anchor positions
const distanceToTL = distanceBetweenPoints(x, y, tlX, tlY);
}Em seguida, verifique se a distância mínima até os quatro pontos de âncora está dentro do limite da interação de redimensionamento. Se estiver, devolva o nome do ícone de estilo do mouse correspondente e some o ângulo de rotação para obter o SVG rotacionado:
if (minDistanceToAnchors <= TRANSFORMER_ANCHOR_RESIZE_RADIUS) {
if (minDistanceToAnchors === distanceToTL) {
return {
anchor: AnchorName.TOP_LEFT,
cursor: 'nwse-resize',
};
}
}Depois, entre na detecção da interação de rotação. Nesse momento, o ponto de detecção não pode estar dentro do Transformer. Dá para usar o método apresentado em Check if Point Is Inside A Polygon:
else if (
!isInside &&
minDistanceToAnchors <= TRANSFORMER_ANCHOR_ROTATE_RADIUS
) {
if (minDistanceToAnchors === distanceToTL) {
return {
anchor: AnchorName.TOP_LEFT,
cursor: 'nwse-rotate',
};
}
}Por fim, chega a detecção de redimensionamento das quatro bordas do Transformer. Aqui é preciso calcular a distância do ponto de detecção até o segmento de reta; veja Gist - point to line 2d:
import distanceBetweenPointAndLineSegment from 'point-to-segment-2d';
const distanceToTopEdge = distanceBetweenPointAndLineSegment(
point,
[tlX, tlY],
[trX, trY],
);
// Omit calculation of distance to other 3 edges
if (minDistanceToEdges <= TRANSFORMER_ANCHOR_RESIZE_RADIUS) {
if (minDistanceToEdges === distanceToTopEdge) {
return {
anchor: AnchorName.TOP_CENTER,
cursor: 'ns-resize',
};
}
}Redimensionar uma única forma
No Figma / FigJam, além de mudar o tamanho livremente arrastando os quatro pontos de âncora dos cantos e as quatro bordas, você também pode:
- Pressionar Option ou Alt enquanto arrasta, para escalar a partir do centro geométrico
- Pressionar Shift enquanto arrasta, para fixar o canto ou a borda opostos e escalar de forma proporcional na horizontal e na vertical
- Combinar essas teclas
O efeito é o seguinte, de Resize, rotate, and flip objects in FigJam:
Vamos ver primeiro como implementar a mudança livre de tamanho. No ponto de âncora superior esquerdo, ao arrastar, o ponto de âncora inferior direito fica fixo:
private handleSelectedResizing(
api: API,
canvasX: number,
canvasY: number,
anchorName: AnchorName,
) {
const { x, y } = api.canvas2Transformer({
x: canvasX,
y: canvasY,
});
if (anchorName === AnchorName.TOP_LEFT) {
// Set top-left anchor point position
Object.assign(tlAnchor.write(Circle), {
cx: x,
cy: y,
});
}
// Omit other anchor point handling logic
{
const { cx: tlCx, cy: tlCy } = tlAnchor.read(Circle);
const { cx: brCx, cy: brCy } = brAnchor.read(Circle);
const width = brCx - tlCx;
const height = brCy - tlCy;
const { x, y } = api.transformer2Canvas({ x: tlCx, y: tlCy });
// Recalculate selected shape position and size
this.fitSelected(api, {
x,
y,
width,
height,
rotation: this.#rotation,
});
}
}Por fim, transforme a forma selecionada com base nos pontos de âncora superior esquerdo e inferior direito.
Transformar a forma
Com as propriedades antes e depois do redimensionamento (transformação e dimensões), é simples calcular a matriz de transição entre esses dois estados:
// @see https://github.com/konvajs/konva/blob/master/src/shapes/Transformer.ts#L1106
[delta transform] = [new transform] * [old transform inverted]const baseSize = 10000000;
const oldTr = mat3.create();
mat3.translate(oldTr, oldTr, [oldAttrs.x, oldAttrs.y]);
mat3.rotate(oldTr, oldTr, oldAttrs.rotation);
mat3.scale(oldTr, oldTr, [
oldAttrs.width / baseSize,
oldAttrs.height / baseSize,
]);
const newTr = mat3.create();
mat3.translate(newTr, newTr, [newAttrs.x, newAttrs.y]);
mat3.rotate(newTr, newTr, newAttrs.rotation);
mat3.scale(newTr, newTr, [
newAttrs.width / baseSize,
newAttrs.height / baseSize,
]);
const delta = mat3.multiply(newTr, newTr, mat3.invert(mat3.create(), oldTr));Essa matriz não pode ser aplicada diretamente à forma selecionada. Também é preciso considerar a transformação do nó pai no grafo de cena, no sistema de coordenadas de mundo:
[delta transform] * [parent transform] * [old local transform] = [parent transform] * [new local transform]A nova transformação da forma selecionada no sistema de coordenadas local sai da multiplicação à esquerda pela transformação de mundo do pai:
[new local] = [parent inverted] * [delta] * [parent] * [old local]Por fim, a nova matriz no sistema de coordenadas local é aplicada à forma selecionada.
Fixar a proporção
Ainda no ponto de âncora superior esquerdo: com a proporção travada, a posição dele não pode ser definida diretamente. É preciso recalcular a posição do ponto superior esquerdo a partir da proporção da forma no início do arraste, mantendo o ponto inferior direito no lugar.
Primeiro, registre a OBB da forma selecionada e a proporção ao começar a arrastar o ponto de âncora, o que equivale à inclinação da diagonal:
if (input.pointerDownTrigger) {
if (type === UIType.TRANSFORMER_ANCHOR) {
this.#obb = this.getSelectedOBB();
const { width, height } = this.#obb;
const hypotenuse = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2));
this.#sin = Math.abs(height / hypotenuse);
this.#cos = Math.abs(width / hypotenuse);
}
}Durante o arraste:
- Mantenha a posição do ponto de âncora inferior direito
- Calcule a distância da diagonal do superior esquerdo até o inferior direito nesse momento
- Recalcule a posição do ponto de âncora superior esquerdo com a proporção salva antes
if (lockAspectRatio) {
// 1.
const comparePoint = {
x: brAnchor.read(Circle).cx,
y: brAnchor.read(Circle).cy,
};
// 2.
newHypotenuse = Math.sqrt(
Math.pow(comparePoint.x - x, 2) + Math.pow(comparePoint.y - y, 2),
);
const { cx, cy } = tlAnchor.read(Circle);
const reverseX = cx > comparePoint.x ? -1 : 1;
const reverseY = cy > comparePoint.y ? -1 : 1;
// 3.
Object.assign(tlAnchor.write(Circle), {
cx: comparePoint.x - newHypotenuse * this.#cos * reverseX,
cy: comparePoint.y - newHypotenuse * this.#sin * reverseY,
});
}Vale observar que o Konva.js não permite arrastar as quatro bordas preservando a proporção. Nesse caso, é preciso desativar esses quatro pontos de âncora. Detalhes em Is there a way to keep the image aspect ratio on transform?.
Aqui isso é simples. No arraste do ponto de âncora da borda direita:
- Primeiro, defina a coordenada X do ponto de âncora inferior direito
- Como a coordenada X do ponto superior esquerdo permanece a mesma, a largura transformada pode ser calculada
- Com a proporção travada, a altura transformada também fica determinada
- Com a diferença de altura antes e depois da transformação, defina as coordenadas Y dos pontos superior esquerdo e inferior direito
if (anchorName === AnchorName.MIDDLE_RIGHT) {
// 1.
brAnchor.write(Circle).cx = x;
if (lockAspectRatio) {
// 2.
const newWidth = brAnchor.read(Circle).cx - tlAnchor.read(Circle).cx;
const tan = sin / cos;
const newHeight = newWidth * tan; // 3.
const deltaY = newHeight - (prevBrAnchorY - prevTlAnchorY);
// 4.
brAnchor.write(Circle).cy = brAnchor.read(Circle).cy + deltaY / 2;
tlAnchor.write(Circle).cy = tlAnchor.read(Circle).cy - deltaY / 2;
}
}Durante o arraste, a diagonal pode ser mostrada em tempo real para dar uma pista clara, em geral uma linha tracejada.
Escala a partir do centro
Ainda no ponto de âncora superior esquerdo, o ponto de referência fixo deixa de ser o inferior direito e passa a ser o centro geométrico, também registrado no início do arraste:
const comparePoint = centeredScaling
? {
x: this.#obb.width / 2,
y: this.#obb.height / 2,
}
: {
x: brAnchor.read(Circle).cx,
y: brAnchor.read(Circle).cy,
};Em seguida, recalcule a posição do ponto de âncora inferior direito, simétrico ao superior esquerdo em relação ao centro:
if (centeredScaling) {
const tlOffsetX = tlAnchor.read(Circle).cx - prevTlAnchorX;
const tlOffsetY = tlAnchor.read(Circle).cy - prevTlAnchorY;
Object.assign(brAnchor.write(Circle), {
cx: brAnchor.read(Circle).cx - tlOffsetX,
cy: brAnchor.read(Circle).cy - tlOffsetY,
});
}Espelhamento
Ao arrastar um ponto de âncora ou uma borda para o lado oposto, ocorre um espelhamento. O efeito a seguir é o do Figma; observe a mudança em Rotation:

Usamos um fundo em gradiente para mostrar esse espelhamento com mais clareza:

O espelhamento que cruza a borda fica desativado por padrão. O exemplo interativo no topo desta página o ativa e usa um gradiente de laranja para azul para deixar o espelhamento visível:
api.setAppState({ flipEnabled: true });Mantenha fixos, durante todo o gesto, o quadro de seleção do pointer down, as transformações dos nós e a identidade das alças. Converta o ponteiro para esse quadro original e calcule dimensões com sinal em torno do canto ou da borda opostos (o centro, quando Alt / Option está pressionado). Uma dimensão negativa significa reflexão. Trocar as alças e tomar dimensões absolutas cedo demais perde essa reflexão, ou desloca a borda fixa no quadro seguinte. No tamanho exatamente zero, use uma dimensão pequena e diferente de zero e continue calculando as amostras seguintes a partir do quadro original.
Depois de aplicar a transformação, mantenha width / height do nó positivos e obtenha rotation e scaleX / scaleY da mesma decomposição de matriz. Combinar a rotação decomposta com os sinais de escala antigos está incorreto. Ao incorporar a geometria de caminho, polilinha ou vector-network, remova a rotação e a reflexão já mantidas no Transform, para que a geometria não seja espelhada duas vezes.
Uma matriz refletida pode ter decomposições equivalentes de rotação e escala. Confira a forma espelhada e o gradiente, a borda fixa e as posições das alças, sem exigir que Rotation mude 180°. Shift preserva a proporção e Alt / Option preserva o centro; o quadro ao soltar, o gesto seguinte e o desfazer/refazer devem permanecer consistentes.
Rotação
Rotação no Figma:
Passe o cursor logo fora de um dos limites da camada até o ícone aparecer. Clique e arraste para rotacionar a seleção: Arraste no sentido horário para criar um ângulo negativo (em direção a -180°). Arraste no sentido anti-horário para criar um ângulo positivo (em direção a 180°). Mantenha Shift pressionado para ajustar a rotação em incrementos de 15.
- No pointer down, salve a OBB e os pivôs local e de mundo. Use o centro geométrico quando não houver um pivô personalizado.
- Acompanhe o ângulo anterior do ponteiro em coordenadas da tela. Acumule os deltas adjacentes de
atan2, normalizados comatan2(sin, cos), para permanecer contínuo ao cruzar ±π. - Calcule a nova origem e a rotação a partir desse pivô fixo e da OBB inicial e, em seguida, atualize os nós por meio de
Select.fitSelected. Durante a rotação de uma seleção múltipla, exiba o mesmo quadro inicial rotacionado; ao soltar, restaure a união alinhada aos eixos do mundo.
O módulo ECS systems/select/rotate-gesture.ts cuida do início do gesto, da amostragem, da conclusão e da limpeza. Select mantém o despacho da entrada, as atualizações dos nós e o histórico. resize-gesture.ts cuida das restrições de redimensionamento, do encaixe, do arraste de extremidades e dos deslocamentos de toque.
// Pointer down, after converting to canvas coordinates and pixel snapping:
beginRotateGesture(api, selection, { x: canvasX, y: canvasY });
// Pointer move:
updateRotateGesture(api, selection, { x: canvasX, y: canvasY }, (obb) => {
this.fitSelected(api, obb, selection);
});
// Pointer up; Select records the resulting document once afterwards:
finishRotateGesture(api, selection);Alterar a origem da rotação
O efeito a seguir é o de Change the rotation origin do Figma:
Notas de implementação:
- Adicione um ponto de âncora central (
AnchorName.CENTER) à máscara do transformer. Esse ponto pode ser arrastado. - Guarde a origem de rotação personalizada em coordenadas locais do transformer (
rotatePivotX/Y) e marque se ela foi fixada pelo usuário (rotatePivotPinned). - No movimento do ponteiro, enquanto o ponto central é arrastado, converta as coordenadas da tela em coordenadas locais do transformer:
const { x, y } = api.canvas2Transformer({ x: canvasX, y: canvasY }, mask);
tf.rotatePivotX = x;
tf.rotatePivotY = y;
tf.rotatePivotPinned = true;- Durante a rotação, use esse pivô (volte ao centro geométrico se ele não estiver definido) e mantenha a posição de mundo do pivô fixa ao calcular a nova origem da OBB:
const {
obb,
pivotWorld: [px, py],
pivotLocal: [lx, ly],
} = gesture;
const rotation = obb.rotation + gesture.accumulated;
const c = Math.cos(rotation);
const s = Math.sin(rotation);
const newAttrs = {
...obb,
x: px - lx * obb.scaleX * c + ly * obb.scaleY * s,
y: py - lx * obb.scaleX * s - ly * obb.scaleY * c,
rotation,
};- Redefina o pivô para o centro quando o conjunto de nós selecionados mudar.
Mover formas com as setas do teclado
O Figma oferece o recurso Nudge layers, que permite mover formas com as setas para cima, para baixo, para a esquerda e para a direita. Shift também serve para deslocamentos maiores. Na nossa implementação, usamos distâncias fixas:
if (e.key === 'ArrowUp') {
e.preventDefault();
this.api.updateNodeOBB(selected, { y: selected.y - 10 });
this.api.record();
}Alinhar e distribuir uma seleção
A API do ECS oferece seis modos de alinhamento: left, right, top, bottom, centerH e centerV. Eles usam os limites da geometria no espaço de mundo, sem a espessura do traço. Só a posição muda: rotação, reflexão, dimensões, dados de caminho, métricas de texto e preferências de tamanho do Flex permanecem intactos, inclusive sob pais rotacionados ou escalados.
const ids = [...api.getAppState().layersSelected];
await api.edit((editor) => {
editor.alignSelectedNodes('left', ids);
});
// Run a separate edit so derived geometry is refreshed between operations.
await api.edit((editor) => {
editor.distributeSelectedNodesSpacing('horizontal', ids);
});O espaçamento aceita horizontal ou vertical, ordena os nós ao longo desse eixo de mundo e calcula folgas iguais de borda a borda a partir da extensão da seleção. As folgas podem ser negativas quando as formas se sobrepõem. IDs duplicados, nós excluídos, ausentes ou bloqueados e transformações de pai não invertíveis são ignorados. Um descendente selecionado segue com o ancestral selecionado, em vez de se mover duas vezes; são necessários pelo menos dois nós elegíveis de nível superior. Os métodos devolvem se algum nó se moveu e não registram sozinhos um layout que não mudou.
O painel de propriedades envia essas operações por api.edit(). Ele conserva a tela e os IDs aceitos pelo clique, resolve os nós ao vivo na fase de escrita e confirma um passo de desfazer. Se uma exclusão ou um bloqueio não deixar mudança, a operação enfileirada é cancelada sem capturar edições pendentes sem relação. Quem chama a API também pode passar um AbortSignal para edit() e cancelar antes da mutação.
Transformer para outras formas
O transformer implementado até aqui serve para formas como círculos, elipses e retângulos. Ele não serve para retas, segmentos de polilinha ou curvas por partes, que exigem mais pontos de controle.
Transformer para linha
Uma reta precisa de apenas dois pontos de âncora, o que simplifica bastante a série de operações baseada no retângulo delimitador que apresentamos antes.
export class Transformable {
@field.ref declare lineMask: Entity;
@field.ref declare x1y1Anchor: Entity;
@field.ref declare x2y2Anchor: Entity;
}
Transformer para polilinha
Em uma polilinha, cada ponto de conexão tem um ponto de controle correspondente, e um ponto extra é inserido entre cada par de pontos de controle adjacentes para gerar um novo segmento de reta:
export class Transformable {
@field.ref declare polylineMask: Entity;
@field.object declare controlPoints: Entity[];
@field.object declare segmentMidpoints: Entity[];
}A interação correspondente é esta: ao passar o mouse sobre um ponto de controle, você pode pressionar Delete para removê-lo. Para acomodar o redimensionamento e a rotação do Transformer retangular e a edição de vértices, também há suporte a duplo clique para entrar no modo de edição de pontos de controle.
Path
O tldraw traz um exemplo: cubic-bezier-shape. Para a edição interativa, dois problemas precisam ser resolvidos:
- Pontos editáveis estáveis: cada âncora e cada ponto de controle de Bézier podem ser selecionados e arrastados, e as mudanças são gravadas de volta em
d. - Ida e volta sem perder a semântica: use, quando possível, um conjunto pequeno e previsível de tipos de comando, e evite escrever à mão um analisador completo de caminho no editor.
Normalizamos d em um vocabulário pequeno de comandos (por exemplo, convertendo comandos relativos em absolutos) e usamos índice do comando + deslocamento da coordenada no array como identidade de cada alça. Durante o arraste, só as coordenadas numéricas mudam; no fim, path2String serializa de volta para uma string.
| Comando | Alças produzidas | Significado de meta |
|---|---|---|
| M / L | Um vértice | coordOffset: 1 → esse ponto (x, y) |
| Q | Controle + fim | deslocamentos 1 e 3 |
| C | cp1, cp2, fim | deslocamentos 1, 3, 5 |
| A | Só o ponto final | deslocamento 6 (arcos não têm alças de Bézier neste editor; só a extremidade fica exposta) |
interface PathControlHandleMeta {
commandIndex: number; // Index in the normalized command array.
coordOffset: number; // Start index of this handle’s coordinates (1-based, since `command[0]` is the command letter).
}
type HandlePoint = {
x: number;
y: number;
meta: PathControlHandleMeta;
};Da mesma forma, dê um duplo clique para entrar no modo de edição de pontos de controle.