デモを読み込み中…
<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'を使ったセクションの並べ替え可能なリストです。セクションが運ばれている間もページは動かず、2 つのセクションの間のラインが行き先を示します:枠ごとに 500px のセクションをスライドさせて道を空けると、ページ全体が揺れてしまうからです。dropzoneはドロップをキャンバス全体に任せるので、ページの横や下で離したセクションも着地します。ColumnList.vue:すべてのセクションのすべての列が、グループblocksのリストです。要素は列やセクションの間を移動し、その場で並べ替わります:ドラッグ中、ページには結果が表示されます。各列はそれぞれ独立したコンポーネントで、列は別のリストのアイテムの中にあるリストです。Palette.vue:セクションのテンプレートは、pull: 'clone'、put: false、sort: falseを指定したページのグループのリストです:コピーを送り出し、何も受け取りません。その下の要素(ElementChip.vue)は通常のドラッグ可能な要素で、列がcreateItemでブロックを作ります。Layers.vue:同じデータを使った、並べ替え可能なツリーとしての同じページ。そこで動かした行はページ上でも動き、その逆も同じです。builder.ts:ページ、選択、デバイス、履歴。ドロップはリストを通じてページに書き込み、ツールバーのボタンとショートカットはここでページを変更します。各変更は元に戻す操作の 1 ステップです。
手に持ったセクションは本物のセクション
SectionGhost.vue は、ページのセクションとパレットのテンプレートの両方のオーバーレイです。ページの上では(to.list がページのリスト)、セクションそのものをページの幅で描画し、ポインターの下で縮小して表示します。ドロップが決まると(status: 'dropping')、ボックスがセクションの位置へ飛ぶ間に倍率が 1 に戻り、ぴったりその位置に着地します。ドロップはそのトランジションを待つので、最後に何かが飛ぶことはありません。
<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 がそのカードのライブラリによるコピーを表示します。ページ上のセクションの位置が枠です:ドラッグ中は薄く表示されてそこで待ち、ドロップすると新しい位置はオーバーレイが着地するまで空のままです。
.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'、デフォルト)、ブロックはドロップ後と同じようにその中でレイアウトされます。
// 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;
});
パレットのチップは、自分の種類を運ぶ通常のドラッグ可能な要素です。各列はそこからブロックを作り、新しいブロックは、離すか別の場所へ移るまで、列自身のテンプレートで描画されてその列に収まります。
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 です:元に戻す操作はページに新しいオブジェクトを入れるので、列はそれに追従する必要があります。
テンプレート:コピーを出し、何も受け取らない
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 で、選択中のセクションの後にセクションを追加します:ドラッグをまったくしなくてもページを組み立てられます。
レイヤー:ツリーとしての同じページ
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'),
});
キーはプロバイダー全体で共有されるので、行は独自のキーを持ちます。列も行で、disabled です:ブロックを受け入れ、その場に留まります。ブロックが入れない場所(2 つのセクションの間)では、ラインが赤くなります。行にホバーするとページ上のそのノードが輪郭で示され、ページ上でノードを選択すると、ここでそのセクションが開いて行が見える位置までスクロールします。
その場での編集とキーボード
ブロックはどこからでもドラッグできるので、テキストはダブルクリックするまで編集できません:ダブルクリックすると contenteditable になり、canDrag がドラッグを断るので、押下でキャレットが置かれます。EditableText.vue はテキストが編集されていない間だけモデルから DOM に書き込むので、キャレットが飛ぶことはなく、元に戻す操作の結果も表示されます。
プロバイダーは Space をドラッグ用に取っておき、:kbd{value="Enter"} はエディターに任せます。
<DnDProvider
:keyboard="{ keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } }"
:announcements="announcements"
:screen-reader-instructions="instructions"
>
Tab でブロックに移ります::kbd{value="Space"} で持ち上げ、矢印キーで列の中を通って次の列へと動かします。:kbd{value="Enter"} で編集します。セクションも同じようにグリップで動かせるほか、ツールバーの矢印でも動かせます。:kbd{value="Ctrl"} Z で元に戻し、:kbd{value="Delete"} で削除し、:kbd{value="Ctrl"} D で複製します。スクリーンリーダーには、id ではなく「the Hero section, position 3 of 7 on the page」と聞こえます。
元に戻す操作と、ドラッグ以外の変更
どのドロップも履歴の 1 ステップです:リストがページを書き込んだ後で、モニターがページを保存します。
useDnDMonitor({ onDragEnd: () => builder.commit() });
元に戻す操作はスナップショットを戻します。リストは animateChanges がオンなので、スナップショットで動いたものはそこへスライドし、ツールバーで上下に動かしたセクションも同じです。追加されたセクションはフェードインし、削除されたセクションはフェードアウトします。
さらに読む:並べ替え可能なリスト · アイテムを共有するリスト · ツリー · オーバーレイ · アニメーション · アクセシビリティ