Документация/Обзор

Холст для дизайна

Редактор в духе Figma — фреймы и слои на масштабируемом холсте, умные направляющие, рамка выделения, Alt для дублирования и панель слоёв, которая и есть дерево дизайна.

Редактировать страницу

Страница переведена ИИ и может содержать неточности. Читать английский оригинал

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: каждый слой — обычный перетаскиваемый элемент, а фрейм тащат за его имя (handle), вместе со слоями внутри. Нажатие выделяет сразу, как в Figma, так что драг берёт выделение провайдера: нажмите на один из нескольких выделенных слоёв, и двинутся все.
  • useLayerDrop.ts: холст и каждый фрейм — цели, которые работают одинаково. Слой, отпущенный над фреймом, перемещается в него; над холстом — из него. Удерживайте Alt, и вместо этого приземляются копии, а слои остаются на месте.
  • LayersPanel.vue: панель — это makeSortableTree на самом дизайне. Порядок строк — порядок наложения, а бросок строки во фрейм перемещает слой в этот фрейм.
  • Canvas.vue: умные направляющие — собственный модификатор демо; makeSelectionArea рисует рамку выделения, а makeDragArea даёт свои правила каждому драгу, который начинается на холсте.
  • editor.ts, geometry.ts и design.ts: дизайн, его история, вид и математика привязки и выравнивания, без всякого drag and drop.

Нарисовано в масштабе

Холст масштабируется без 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. Он раскладывает перетаскиваемые слои так, как они лежат: 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, так как слои появляются ровно там, где отпустили их клоны. Инструменты на панели инструментов — тоже обычные перетаскиваемые элементы: брошенные на фрейм, они добавляют в него свой слой с центром в точке броска; по клику — в середину вида.

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: нажатие забирает самая внутренняя область, и ей принадлежат перетаскиваемые элементы внутри неё. Shift добавляет к выделению (strategy: 'toggle').

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

Редактор никогда не выделяет фрейм вместе со слоем внутри него: при драге слой сдвинулся бы дважды. Рамка захватывает указатель, когда начинается, поэтому клик по пустому месту фрейма никогда не доходит до фрейма: Artboard.vue выделяет фрейм, когда нажатие там отпущено в том же месте, где началось.

Панель слоёв

Элементы дерева — это дизайн: фреймы со своими children, верхняя строка — впереди. canNest позволяет фреймам принимать слои и держит фреймы на верхнем уровне. У строк собственные ключи (row:…), так как id слоёв принадлежат их перетаскиваемым элементам на холсте; выделение — то же, что у холста, так что строка подсвечивается вместе со своим слоем.

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'), поэтому холст и фреймы, которые принимают слои и инструменты, её отклоняют.

Клавиатура

Перейдите табом к слою (он выделится), затем Enter берёт его, стрелки двигают его на 10 пикселей (Shift: на 40), а Enter бросает его во фрейм под ним. Без драга стрелки сдвигают выделение на пиксель. Инструмент берётся так же, а его стрелки прыгают между холстом и фреймами (keyboardMode: 'droppables'). Остальные сочетания клавиш перечислены в панели Design, когда ничего не выделено.

Подробнее: drag and drop · свой модификатор · правила для части страницы · выделение рамкой · деревья