문서/개요

디자인 캔버스

Figma 같은 에디터. 확대·축소되는 캔버스 위의 프레임과 레이어, 스마트 가이드, 선택 사각형, Alt로 복제, 그리고 디자인의 트리 그 자체인 레이어 패널.

이 페이지 편집

이 페이지는 AI가 번역했으며 부정확한 내용이 있을 수 있습니다. 영어 원문 읽기

Live

데모 불러오는 중…

<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>

살펴볼 점

  • **Shape.vue**와 Artboard.vue: 모든 레이어는 일반 draggable이고, 프레임은 이름 부분 (handle)으로 드래그하며 안의 레이어도 함께 움직입니다. Figma처럼 누르는 즉시 선택되므로, 드래그가 가져가는 것은 프로바이더의 선택입니다. 선택된 여러 레이어 중 하나를 누르면 모두 움직입니다.
  • useLayerDrop.ts: 캔버스와 모든 프레임은 똑같이 동작하는 드롭 대상입니다. 프레임 위에서 놓은 레이어는 그 프레임 안으로 들어가고, 캔버스 위에서 놓으면 프레임 밖으로 나옵니다. Alt를 누르고 있으면 대신 복사본이 안착하고, 레이어는 원래 자리에 남습니다.
  • LayersPanel.vue: 패널은 디자인 자체에 대한 makeSortableTree입니다. 행의 순서가 쌓임 순서이고, 행을 프레임 안으로 드롭하면 레이어가 그 프레임으로 옮겨집니다.
  • Canvas.vue: 스마트 가이드는 데모가 직접 만든 모디파이어입니다. makeSelectionArea가 선택 사각형을 그리고, makeDragArea가 캔버스에서 시작하는 모든 드래그에 규칙을 줍니다.
  • editor.ts, geometry.ts, design.ts: 디자인, 그 기록, 보기, 그리고 스냅과 정렬 계산으로, 드래그 앤 드롭은 전혀 없습니다.

확대 배율로 그리기

캔버스는 CSS scale() 없이 확대·축소합니다. 모든 레이어는 크기에 확대 배율을 곱한 크기로, 화면 픽셀 단위로 배치됩니다. 그래서 라이브러리가 측정하는 것이 곧 화면에 보이는 것이고, 드롭 대상, 선택 사각형, 오버레이 안의 클론에 특별한 처리가 필요 없습니다. 캔버스 안의 세계는 팬으로만, 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` };
});

휠로 팬과 확대·축소(Ctrl 또는 핀치)를 하고, Space를 누른 채 드래그하기, 손 도구, 모서리의 버튼으로도 할 수 있습니다. 크기 조절 핸들과 마찬가지로 평범한 포인터 이벤트와 휠 이벤트입니다. Space가 손 도구이므로, 레이어를 집는 것은 Enter뿐입니다(프로바이더의 keyboard.keys).

프레임 사이로 레이어 옮기기

레이어는 자체 오버레이 DragGroup.vue가 있는 일반 draggable입니다. 오버레이는 드래그 중인 레이어를 놓인 모양 그대로 배치합니다. 각 레이어의 DragPreview(집을 때 만든 클론)를 레이어의 data를 바탕으로 그려진 자리에 놓습니다. 클론이 움직이는 동안 Alt를 누르고 있지 않으면 레이어 자체는 자리를 비켜 줍니다.

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

드롭할 때 position은 집은 레이어의 모서리를 대상 안에서 놓은 위치를 화면 픽셀로 나타낸 값입니다. 이를 확대 배율로 나누면 그 레이어가 세계 안에서 얼마나 움직였는지 알 수 있고, 드래그한 모든 레이어가 그만큼 프레임 안으로, 또는 캔버스 위로 옮겨집니다.

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);

프레임은 레이어를 받고 프레임은 받지 않습니다. 프레임을 옮기는 드래그는 다른 프레임(disabled, 실시간으로 읽음)을 지나쳐 캔버스로 갑니다. 레이어는 클론을 놓은 바로 그 자리에 나타나므로 대상은 overDropAnimation: false입니다. 툴바의 도구도 일반 draggable입니다. 프레임에 드롭하면 드롭 지점을 중심으로 레이어를 프레임에 추가하고, 클릭하면 보기의 가운데에 추가합니다.

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),
});

스마트 가이드

smartGuides는 모디파이어입니다. 드래그의 오프셋을 받아 드래그 중인 레이어가 세계 안의 어디에 올지 계산하고, 화면에서 6픽셀 안에 있는 다른 레이어의 가장 가까운 가장자리나 중심으로 끌어당깁니다. 오버레이, 그 아래의 대상, 드롭이 모두 이를 따릅니다. 모니터가 드래그의 delta(모디파이어를 거친 오프셋)로 분홍색 선과 거리를 그립니다.

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 });

크기 조절 핸들도 같은 선에 스냅하고 같은 가이드를 그립니다.

선택하기

빈 캔버스에 그린 선택 사각형은 프레임과 프레임에 속하지 않은 레이어를 선택하고, 프레임 안에 그리면 그 프레임의 레이어를 선택합니다. 둘 다 makeSelectionArea입니다. 가장 안쪽 영역이 누르기를 받고, 그 안의 draggable을 맡습니다. Shift는 선택에 더합니다(strategy: 'toggle').

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

에디터는 프레임과 그 안의 레이어를 함께 선택하지 않습니다. 드래그하면 그 레이어가 두 번 움직이기 때문입니다. 선택 사각형은 시작할 때 포인터를 캡처하므로, 프레임의 빈 곳을 클릭해도 프레임에 전달되지 않습니다. Artboard.vue는 그곳에서 누른 자리 그대로 놓았을 때 프레임을 선택합니다.

레이어 패널

트리의 항목은 디자인 그 자체입니다. children이 있는 프레임이며, 맨 위 행이 맨 앞입니다. canNest는 프레임이 레이어를 받게 하고, 프레임은 최상위에 머물게 합니다. 레이어의 id는 캔버스의 draggable 것이므로 행은 자체 key(row:…)를 가집니다. 선택은 캔버스의 것이므로 행은 그 레이어와 함께 강조됩니다.

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은 트리가 기록되기 전에 실행되므로, 에디터는 각 레이어가 어느 프레임에서 왔는지 아직 알고 있으며, 실행 취소를 위해 디자인을 저장합니다. 레이어를 덮지 않는 프레임으로 옮긴 레이어는 잘려서 보이지 않게 되므로, 대신 프레임 가운데로 갑니다. animateChanges 덕분에 캔버스에서의 변경과 실행 취소에도 행이 미끄러집니다. 행에는 타입이 있으므로(type: 'row'), 레이어와 도구를 받는 캔버스와 프레임은 행을 거부합니다.

키보드

Tab으로 레이어에 가서(그 레이어가 선택됩니다) Enter로 집고, 화살표 키로 10픽셀 (Shift를 누르면 40) 옮기고, Enter로 그 아래 프레임에 드롭합니다. 드래그 중이 아니면 화살표 키는 선택을 1픽셀씩 옮깁니다. 도구도 같은 방식으로 집으며, 그 화살표 키는 캔버스와 프레임 사이를 건너뜁니다(keyboardMode: 'droppables'). 다른 단축키는 아무것도 선택하지 않았을 때 Design 패널에 나옵니다.

더 읽어 보기: 드래그 앤 드롭 · 직접 만든 모디파이어 · 페이지 일부를 위한 규칙 · 사각형 선택 · 트리