문서/개요

페이지 빌더

드래그 앤 드롭으로 조립하는 랜딩 페이지. 페이지 위에서 실제 모습으로 바뀌는 팔레트의 섹션, 컬럼 사이를 오가는 요소, 같은 데이터를 쓰는 레이어 트리, 그 자리에서 하는 편집, 기기 너비, 실행 취소.

이 페이지 편집

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

Live

데모 불러오는 중…

<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type AnnouncedPosition, type Announcements } from '@vue-dnd-kit/core';
import Builder from './Builder.vue';
import { BUILDER, LAYERS, PAGE, createBuilder } from './builder';
import { BLOCKS, SECTIONS, excerpt, isColumn, isSection, type ChipData, type LayerNode } from './page';

// Provided above the provider: the overlays it renders copy the page's look from it.
const builder = createBuilder();
provide(BUILDER, builder);

/** What a drag carries, in words: "the Hero section", "a new Pricing section", "the heading “…”". */
const what = (active: Active) => {
  const node = active.items[0]?.item as LayerNode | undefined;
  // A chip of the palette: a plain draggable, with no list item.
  if (!node) return `a new ${BLOCKS[(active.data as ChipData).kind].name.toLowerCase()}`;
  if (isSection(node)) return `${builder.find(node.id) ? 'the' : 'a new'} ${SECTIONS[node.kind].name} section`;
  if (isColumn(node)) return 'the column';
  return `the ${BLOCKS[node.kind].name.toLowerCase()} “${excerpt(node)}”`;
};

/** Where a position of a list is, in words. */
const where = ({ list, index, length }: AnnouncedPosition) => {
  const place = `${index + 1} of ${length}`;
  if (list === PAGE) return `position ${place} on the page`;
  if (list === LAYERS) return `row ${place} of the layers`;
  const at = builder.find(String(list));
  if (!at?.column) return `position ${place}`;
  return `position ${place} in column ${at.section.columns.indexOf(at.column) + 1} of ${SECTIONS[at.section.kind].name}`;
};

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }) => `Picked up ${what(active)}.`,
  onDragOver: ({ active }, { position }) => (position ? `${what(active)}, ${where(position)}` : undefined),
  onDragEnd: ({ active, dropped }, { position }) => (dropped && position ? `Dropped ${what(active)} at ${where(position)}.` : undefined),
  onDragCancel: ({ active }) => `Cancelled. Nothing changed for ${what(active)}.`,
};

// Space picks up and drops. Enter is left to the editor: it edits a text, adds a template.
const keyboard = { keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } };
const instructions =
  'Space picks up; the arrow keys move; Space drops; Escape cancels. Enter edits the focused text, or adds the focused template.';
</script>

<template>
  <DnDProvider :announcements="announcements" :keyboard="keyboard" :screen-reader-instructions="instructions">
    <Builder />
  </DnDProvider>
</template>

프레임을 전체 화면으로 펼쳐 보세요. 팔레트는 왼쪽에, 레이어는 오른쪽에 고정되고, 페이지가 그 사이를 차지합니다. 더 좁으면 둘이 상단 바에서 여는 서랍을 함께 씁니다.

살펴볼 점

  • Canvas.vue: 페이지는 placeholder: 'indicator'를 쓰는 섹션의 정렬 가능한 리스트입니다. 섹션이 옮겨지는 동안 페이지는 움직이지 않고, 두 섹션 사이의 선이 어디로 갈지 알려 줍니다. 자리마다 500px짜리 섹션을 밀어내면 페이지 전체가 흔들리기 때문입니다. dropzone이 드롭을 캔버스 전체에 맡기므로, 페이지 옆이나 아래에서 놓은 섹션도 안착합니다.
  • ColumnList.vue: 모든 섹션의 모든 컬럼이 blocks 그룹의 리스트입니다. 요소는 컬럼과 섹션 사이를 옮겨 다니며 실시간으로 정렬됩니다. 드래그하는 동안 페이지가 결과를 보여 줍니다. 각 컬럼은 별도의 컴포넌트이며, 컬럼들은 다른 리스트의 항목 안에 있는 리스트입니다.
  • Palette.vue: 섹션 템플릿은 pull: 'clone', put: false, sort: false인 페이지 그룹의 리스트입니다. 복사본을 내주고 아무것도 받지 않습니다. 그 아래의 요소(ElementChip.vue)는 일반 draggable이며, 컬럼이 createItem으로 블록을 만듭니다.
  • Layers.vue: 같은 데이터를 쓰는, 정렬 가능한 트리로 본 같은 페이지. 그곳에서 옮긴 행은 페이지에서도 옮겨지고, 그 반대도 마찬가지입니다.
  • builder.ts: 페이지, 선택, 기기, 기록. 드롭은 리스트를 통해 페이지에 기록되고, 툴바의 버튼과 단축키는 여기서 페이지를 바꿉니다. 각 변경은 실행 취소의 한 단계입니다.

손에 든 섹션은 실제 섹션입니다

**SectionGhost.vue**는 페이지의 섹션과 팔레트의 템플릿 둘 다의 오버레이입니다. 페이지 위에서는 (to.list가 페이지의 리스트) 섹션 자체를 페이지 너비로 렌더링하고, 포인터 아래에서 축소해 보여 줍니다. 드롭이 정해지면(status: 'dropping') 박스가 섹션의 자리로 날아가는 동안 배율이 1로 돌아와, 그 자리에 정확히 안착합니다. 드롭은 그 트랜지션을 기다리므로 마지막에 튀는 것이 없습니다.

SectionGhost.vue
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; status: DnDStatus; to: DropDestination | null }>();
const section = computed(() => props.items[0]!.item as Section);
const fromPalette = computed(() => !find(section.value.id));
const onPage = computed(() => props.to?.list === PAGE);

const look = computed(() => {
  if (fromPalette.value && !onPage.value) return 'card'; // still the palette's card
  return props.status === 'dropping' ? 'landing' : 'ghost';
});
</script>

<template>
  <DragPreview v-if="look === 'card'" :id="items[0]!.id" :style="{ width: '100%', height: '100%' }" />
  <div v-else class="replica" :class="look" :style="{ width: `${pageWidth}px`, '--shrink': shrink }">
    <SectionView :section="section" />
  </div>
</template>

<style scoped>
.replica { transform-origin: 0 0; scale: var(--shrink); transition: scale 0.25s ease; }
.replica.landing { scale: 1; }
</style>

페이지에서 벗어나 있는 동안 템플릿은 팔레트에 있던 모습 그대로입니다. DragPreview가 그 카드의 라이브러리 복사본을 보여 줍니다. 페이지에서 섹션이 있는 자리가 곧 자리입니다. 드래그하는 동안 흐린 모습으로 그곳에서 기다리고, 드롭하면 새 자리는 오버레이가 안착할 때까지 비어 있습니다.

PageSection.vue
.page-section[data-dnd-placeholder] { opacity: 0; }
html[data-dnd-dragging] .page-section[data-dnd-placeholder] { opacity: 0.35; }

각 섹션은 자신의 indicator에서 선을 그리고, 드롭이 할 일을 알려 줍니다. Move Features here, 템플릿이면 Add Pricing here.

요소: 들어갈 섹션의 색으로

블록은 몸체 전체로 드래그하고, 섹션은 몸체가 클릭하고 드래그하는 콘텐츠이므로 태그의 그립으로 드래그합니다. 블록과 칩의 오버레이인 **BlockGhost.vue**는 들어갈 컬럼을 to에서 읽고 그 섹션의 색을 입습니다. 어두운 푸터 위의 버튼은 놓기 전에 밝아집니다. 박스는 그곳에서의 블록 자리 크기를 가지며 (overlayFit: 'slot', 기본값), 블록은 드롭한 뒤와 똑같이 그 안에 배치됩니다.

BlockGhost.vue
// The column it would go into (`to.list` is the column's id): its section's tone.
const look = computed(() => {
  const at = props.to && find(String(props.to.list));
  return at ? { ...TONES[SECTIONS[at.section.kind].tone], ...typeScale(pageWidth.value) } : null;
});

팔레트의 칩은 자기 종류를 담은 일반 draggable입니다. 각 컬럼은 칩으로 블록을 만들고, 새 블록은 놓거나 다른 곳으로 옮길 때까지 컬럼 자체의 템플릿으로 렌더링되어 그 컬럼에 놓입니다.

ColumnList.vue
makeSortableList(list, {
  id: props.column.id,
  items: blocks,
  group: 'blocks',
  overlay: BlockGhost,
  createItem: (active) => {
    const kind = (active.data as ChipData | undefined)?.kind;
    return kind ? createBlock(BLOCKS[kind].spec) : undefined;
  },
});

blocks는 toRef가 아니라 props.column.blocks에 대한 쓰기 가능한 computed입니다. 실행 취소가 페이지에 새 객체를 넣으므로 컬럼이 그것을 따라가야 하기 때문입니다.

템플릿: 복사본은 내보내고, 받는 것은 없음

Palette.vue
makeSortableList(list, {
  id: 'templates',
  items: templates,
  group: { name: 'sections', pull: 'clone', put: false },
  sort: false,
  clone: (template) => createSection(template.kind), // new ids all the way down
  overlay: SectionGhost,
});

템플릿은 실제 섹션이므로 카드가 그것을 (축소해서) 보여 주고 오버레이도 렌더링할 수 있습니다. 카드를 클릭하거나 Enter를 누르면 선택한 섹션 뒤에 섹션을 추가합니다. 드래그를 전혀 하지 않고도 페이지를 만들 수 있습니다.

레이어: 트리로 본 같은 페이지

Layers.vue
makeSortableTree(tree, {
  id: LAYERS,
  items: sections as Ref<LayerNode[]>,
  // Keys of their own: the canvas's lists already drag these ids.
  getKey: (node) => `layer-${node.id}`,
  getChildren: (node) => (isSection(node) ? node.columns : isColumn(node) ? node.blocks : undefined),
  setChildren: (node, children) => { … },
  // Blocks go into columns, nothing into blocks, sections stay at the top.
  canNest: (node, parent) => levelOf(node) === levelOf(parent) + 1,
  maxDepth: 2,
  placeholder: 'indicator',
  // A tree takes any row at its top level: a block let go there flies back.
  onBeforeDrop: (event) => (event.to.parentKey !== null || event.items.every(isSection) ? 'accept' : 'reject'),
});

key는 프로바이더 전체가 공유하므로 행은 자체 key를 가집니다. 컬럼도 행이며 disabled입니다. 블록을 받고 제자리에 머뭅니다. 블록이 갈 수 없는 곳(두 섹션 사이)에서는 선이 빨갛게 변합니다. 행 위에 있으면 페이지에서 그 노드에 윤곽선이 그려지고, 페이지에서 노드를 선택하면 여기서 그 섹션이 열리며 행이 보이도록 스크롤됩니다.

그 자리에서 편집하기, 그리고 키보드

블록은 어디서든 드래그되므로, 더블클릭하기 전까지 텍스트를 편집할 수 없습니다. 더블클릭하면 contenteditable이 되고, canDrag가 드래그를 거절하므로 누르면 캐럿이 놓입니다. **EditableText.vue**는 텍스트를 편집하지 않는 동안에만 모델에서 DOM으로 기록하므로, 캐럿이 튀지 않고 실행 취소도 그대로 보입니다.

프로바이더는 Space를 드래그용으로 두고, Enter는 에디터에 맡깁니다.

index.vue
<DnDProvider
  :keyboard="{ keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } }"
  :announcements="announcements"
  :screen-reader-instructions="instructions"
>

Tab으로 블록에 가세요. Space로 집고, 화살표 키로 컬럼 안을 지나 다음 컬럼으로 옮깁니다. Enter는 편집합니다. 섹션도 같은 방식으로 그립으로 옮기거나 툴바의 화살표로 옮깁니다. Ctrl Z는 실행 취소, Delete는 제거, CtrlD는 복제합니다. 스크린 리더는 id 대신 "the Hero section, position 3 of 7 on the page"를 듣습니다.

실행 취소, 그리고 드래그가 아닌 변경

모든 드롭은 기록의 한 단계입니다. 리스트가 페이지를 기록한 뒤 모니터가 페이지를 저장합니다.

Builder.vue
useDnDMonitor({ onDragEnd: () => builder.commit() });

실행 취소는 스냅숏을 되돌립니다. 리스트에 animateChanges가 켜져 있으므로 스냅숏이 옮긴 것은 그 자리로 미끄러지고, 툴바로 위아래로 옮긴 섹션도 마찬가지입니다. 추가된 섹션은 서서히 나타나고 삭제된 섹션은 서서히 사라집니다.

더 읽어 보기: 정렬 가능한 리스트 · 항목을 공유하는 리스트 · 트리 · 오버레이 · 애니메이션 · 접근성