Documentação/Visão geral

Canvas de design

Um editor no estilo Figma — frames e camadas em um canvas com zoom, guias inteligentes, seleção por retângulo, Alt para duplicar e um painel de camadas que é a árvore do design.

Editar esta página

Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês

Live

Carregando a demo…

<script setup lang="ts">
import { DnDProvider, type Announcements } from '@vue-dnd-kit/core';
import DesignCanvas from './DesignCanvas.vue';
import { KINDS } from './design';
import type { DragData } from './editor';
import type { ToolData } from './useLayerDrop';

// Screen readers hear names, not ids: a layer's, or the layer a tool adds. Rows of the
// layers panel keep the built-in texts (`undefined`), which say where they are in the tree.
const nameOf = (data: unknown) => {
  const { layer, tool } = (data ?? {}) as Partial<DragData & ToolData>;
  return layer?.name ?? (tool && `New ${KINDS[tool].name.toLowerCase()}`);
};
const placeOf = (over: { id: unknown; data: unknown }) =>
  over.id === 'canvas' ? 'the canvas' : `frame ${(over.data as { name: string }).name}`;

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }, { count }) => {
    const name = nameOf(active.data);
    if (!name) return undefined;
    const others = count > 1 ? ` and ${count - 1} more` : '';
    return `Picked up ${name}${others}. Arrow keys move it, Enter drops it, Escape cancels.`;
  },
  onDragOver: ({ active, over }) => (nameOf(active.data) ? (over ? `Over ${placeOf(over)}` : 'Off the canvas') : undefined),
  onDragEnd: ({ active, over, dropped }) => {
    const name = nameOf(active.data);
    if (!name) return undefined;
    return dropped && over ? `${name} dropped on ${placeOf(over)}.` : `${name} went back.`;
  },
  onDragCancel: ({ active }) => (nameOf(active.data) ? `${nameOf(active.data)} went back.` : undefined),
};
</script>

<template>
  <!-- Space pans, as in any design tool, so Enter alone picks a layer up. Every dragged
       layer gets its clone at pickup, and the page doesn't scroll under a drag. -->
  <DnDProvider
    :keyboard="{ keys: { start: ['Enter'], drop: ['Enter'] }, step: 10 }"
    overlay-previews="all"
    :auto-scroll="false"
    :announcements="announcements"
    screen-reader-instructions="Press Enter to pick up a layer, the arrow keys to move it, Enter to drop it and Escape to cancel. Without a drag, the arrow keys nudge the selected layers."
  >
    <DesignCanvas />
  </DnDProvider>
</template>

O que observar

  • Shape.vue e Artboard.vue: toda camada é um draggable simples, e um frame é arrastado pelo nome (handle), com as suas camadas dentro dele. Uma pressão seleciona na hora, como no Figma, então a seleção do provider é o que um arraste leva: pressione uma de várias camadas selecionadas e todas se movem.
  • useLayerDrop.ts: o canvas e todo frame são alvos de drop que funcionam do mesmo jeito. Uma camada solta sobre um frame entra nele; sobre o canvas, sai dele. Segure Alt e, em vez disso, pousam cópias, e as camadas ficam onde estavam.
  • LayersPanel.vue: o painel é makeSortableTree sobre o próprio design. A ordem das linhas é a ordem de empilhamento, e soltar uma linha dentro de um frame move a camada para esse frame.
  • Canvas.vue: as guias inteligentes são um modificador da própria demo; makeSelectionArea desenha o retângulo de seleção, e makeDragArea dá a todo arraste que começa no canvas as suas regras.
  • editor.ts, geometry.ts e design.ts: o design, o seu histórico, a visualização e a matemática de encaixar e alinhar, sem nenhum arrastar e soltar neles.

Desenhado no zoom

O canvas dá zoom sem scale() do CSS: toda camada é disposta no seu tamanho vezes o zoom, em pixels de tela. O que a biblioteca mede é então o que está na tela, então os alvos de drop, o retângulo de seleção e os clones no overlay não precisam de nada especial. O mundo dentro do canvas é movido só pelo pan, com translate().

Shape.vue
const style = computed(() => {
  const { zoom } = editor.view;
  const { x, y, width, height } = props.shape;
  return { left: `${x * zoom}px`, top: `${y * zoom}px`, width: `${width * zoom}px`, height: `${height * zoom}px` };
});

A roda do mouse faz pan e zoom (Ctrl ou uma pinça), assim como arrastar com Space, a ferramenta de mão e os botões no canto: eventos de ponteiro e de roda simples, como as alças de redimensionar. Como Space é a mão, uma camada é pega só com Enter (o keyboard.keys do provider).

Mover camadas entre frames

Uma camada é um draggable simples com um overlay próprio, DragGroup.vue. Ele dispõe as camadas arrastadas como elas estão: um DragPreview (o clone feito ao pegar) de cada uma, posicionado a partir do data da sua camada, onde ela é desenhada. As próprias camadas saem de cena enquanto os clones delas se movem, a menos que Alt esteja segurado.

Shape.vue
const { isDragging, selected } = makeDraggable(el, {
  id: () => props.shape.id,
  type: 'layer',
  data: () => editor.dragData(props.shape), // { layer, left, top }
  overlay: DragGroup,
});

No drop, position é onde o canto da camada pega foi solto dentro do alvo, em pixels de tela. Dividido pelo zoom, ele diz quanto essa camada andou no mundo, e toda camada arrastada se move a mesma distância, para dentro do frame ou para o canvas:

useLayerDrop.ts
const corner = target ? editor.boxOf(target) : { x: -view.x / view.zoom, y: -view.y / view.zoom };
const toWorld = (x: number, y: number) => ({ x: corner.x + x / view.zoom, y: corner.y + y / view.zoom });

const from = editor.boxOf(grabbed); // the layer of `active.id`
const to = toWorld(position.x, position.y);
editor.move(active.ids, { x: to.x - from.x, y: to.y - from.y }, target, editor.altHeld.value);

Um frame aceita camadas, não frames: um arraste que leva um frame passa sobre os outros frames (disabled, lido ao vivo) até o canvas. Os alvos têm overDropAnimation: false, já que as camadas aparecem exatamente onde os clones delas foram soltos. As ferramentas da barra também são draggables simples: soltas em um frame, adicionam a sua camada dentro dele, centralizada no ponto do drop; clicadas, no meio da visualização.

Artboard.vue
makeDroppable(body, {
  id: () => props.frame.id,
  accept: 'layer',
  disabled: () => !!active.value?.items.some((item) => item.type.includes('frame')),
  overDropAnimation: false,
  onDrop: useLayerDrop(() => props.frame),
});

Guias inteligentes

smartGuides é um modificador: ele recebe o deslocamento do arraste, calcula onde as camadas arrastadas estariam no mundo, e as puxa para a borda ou o centro mais próximo de outra camada a até 6 pixels de tela. O overlay, o alvo sob ele e o drop acompanham. Um monitor desenha as linhas cor de rosa e as distâncias a partir do delta do arraste, o deslocamento depois dos modificadores.

Canvas.vue
const smartGuides: Modifier = ({ transform, active }) => {
  const moving = editor.boundsOf(active.ids);
  if (!moving) return transform; // a tool from the toolbar
  const { zoom } = view;
  const box = offsetBox(moving, transform.x / zoom, transform.y / zoom);
  const targets = editor.snapTargets(active.ids);
  return {
    x: transform.x + snapBox(box, targets, 'x', SNAP / zoom) * zoom,
    y: transform.y + snapBox(box, targets, 'y', SNAP / zoom) * zoom,
  };
};

// Every drag that starts on the canvas, frames and layers alike, gets the guides.
makeDragArea(viewport, { modifiers: [smartGuides], disabled: panning });

As alças de redimensionar encaixam com as mesmas linhas e desenham as mesmas guias.

Selecionar

Um retângulo desenhado no canvas vazio seleciona frames e camadas soltas; desenhado dentro de um frame, as camadas do frame. Os dois são makeSelectionArea: a área mais interna recebe a pressão, e é dona dos draggables dentro dela. Shift adiciona à seleção (strategy: 'toggle').

Artboard.vue
const { isSelecting, style: band } = makeSelectionArea(body, {
  strategy: () => (editor.shiftHeld.value ? 'toggle' : 'select'),
  disabled: editor.panning,
});

O editor nunca seleciona um frame junto com uma camada dentro dele: arrastada, a camada se moveria duas vezes. Um retângulo captura o ponteiro quando começa, então um clique no espaço vazio de um frame nunca chega ao frame: Artboard.vue seleciona o frame quando uma pressão ali é solta onde começou.

O painel de camadas

Os itens da árvore são o design: frames com os seus children, a linha de cima na frente. canNest deixa os frames aceitarem camadas e mantém os frames no nível de cima. As linhas têm keys próprias (row:…), já que os ids das camadas pertencem aos draggables delas no canvas; a seleção é a do canvas, então uma linha acende junto com a sua camada.

LayersPanel.vue
makeSortableTree(tree, {
  items: editor.layers,
  getKey: (layer) => `row:${layer.id}`,
  canNest: (layer, parent) => parent.kind === 'frame' && layer.kind !== 'frame',
  maxDepth: 1,
  animateChanges: true,
  onDrop: ({ items, from, to }) => {
    // Positions count from the frame's corner: a layer changing frames takes the
    // difference, so it stays where it is on the canvas.
  },
});

onDrop roda antes de a árvore ser escrita, então o editor ainda sabe de que frame cada camada vem, e salva o design para o desfazer. Uma camada movida para um frame que não a cobre seria recortada para fora da vista: em vez disso, ela vai para o meio do frame. Com animateChanges, as linhas deslizam também nas mudanças feitas no canvas e pelo desfazer. Uma linha tem tipo (type: 'row'), então o canvas e os frames, que aceitam camadas e ferramentas, a recusam.

Teclado

Chegue a uma camada com Tab (ela fica selecionada), depois Enter a pega, as setas a movem 10 pixels (Shift: 40), e Enter a solta no frame sob ela. Sem arraste, as setas empurram a seleção um pixel. Uma ferramenta é pega do mesmo jeito, e as setas dela pulam entre o canvas e os frames (keyboardMode: 'droppables'). Os outros atalhos estão listados no painel Design quando nada está selecionado.

Leia mais: Arrastar e soltar · Um modificador seu · Regras para uma parte da página · Seleção por retângulo · Árvores