ドキュメント/概要

ブロックエディター

Notion 風のページ。ハンドルでドラッグし横方向にネストするブロック、複数をまとめて動かす選択枠、「/」メニューやサイドバーからドラッグして出す新しいブロック、Alt でコピー、そしてそのすべてに使える Undo。

このページを編集

このページは AI によって翻訳されており、不正確な箇所が含まれている可能性があります。 英語の原文を読む

Live

デモを読み込み中…

<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type AnnouncementContext, type Announcements } from '@vue-dnd-kit/core';
import Workspace from './Workspace.vue';
import { KINDS, describe, type BlockKind } from './blocks';
import { EDITOR, createEditor } from './editor';

// Provided above the provider: the overlay it renders reads the document too.
const editor = createEditor();
provide(EDITOR, editor);

// Screen readers hear blocks by their kind and text, not by their ids.
const name = (active: Active, { count, copy }: AnnouncementContext) => {
  const block = editor.get(String(active.id));
  const kind = (active.data as { kind?: BlockKind } | undefined)?.kind;
  const what = block ? describe(block) : `a new ${KINDS[kind ?? 'text'].name.toLowerCase()}`;
  return `${copy ? 'a copy of ' : ''}${what}${count > 1 ? ` and ${count - 1} more` : ''}`;
};
const row = ({ position }: AnnouncementContext) => (position ? `row ${position.index + 1} of ${position.length}` : '');

const announcements: Partial<Announcements> = {
  onDragStart: ({ active }, context) =>
    `Picked up ${name(active, context)}. Up and down move it, left and right change its level.`,
  onDragOver: (_event, context) => row(context) || 'Outside the page: dropping cancels.',
  onDragEnd: ({ active }, context) => `Dropped ${name(active, context)}${context.position ? `, ${row(context)}` : ''}.`,
  onDragCancel: ({ active }, context) => `Cancelled: ${name(active, context)} stays where it was.`,
};
</script>

<template>
  <DnDProvider
    :announcements="announcements"
    screen-reader-instructions="Space picks a block up, the arrow keys move it and left or right change its level, Space drops it and Escape cancels. Enter opens the block's menu."
  >
    <Workspace />
  </DnDProvider>
</template>

各ブロックには、余白にハンドル(⋮⋮)と「+」があります。どのブロックにも入力できます:Enter で次のブロックを始め、Tab でネストし、「/」で新しいブロックのメニューを開き、Markdown も使えます(「## 」、「- 」、「 」)。余白に枠を描いて複数のブロックを選択し、まとめてドラッグしてみましょう。フレームを展開すると、幅の広い画面では追加するブロックとページのアウトラインがそれぞれのサイドバーに表示されます。

注目ポイント

  • Page.vue:ページは 1 つの並べ替え可能なツリーで、placeholder: 'indicator' を使っています:ドラッグ中もページは動かず、ブロックがどこにどの階層で着地するかをラインが示します。ブロックを囲むシートがその dropzone なので、最後のブロックの下でドロップすると末尾に着地します。
  • BlockRow.vue:1 つのブロック。ドラッグするのはハンドルだけなので(handle)、テキストは編集も選択もできるままです。ハンドルはボタンです:クリックでブロックを選択してそのメニューを開き、Space でキーボード用にブロックを持ち上げます。
  • BlockText.vue と Editable.vue:エディターのキー操作を持つ、ただの contenteditable のテキスト。入力は DOM からモデルへ流れ、モデルが DOM に戻るのはほかの何か(Undo、結合)がモデルを変えたときだけなので、キャレットが飛ぶことはありません。
  • editor.ts:ドキュメントとそれに対するすべての編集で、ドラッグ&ドロップは含みません:分割、結合、インデント、複製、変換、そしてドロップも入力も同じように取り消せる履歴。

横にドラッグしてネストする

Page.vue
const { items } = makeSortableTree(root, {
  id: 'page',
  items: editor.blocks,
  group: 'blocks',
  placeholder: 'indicator',
  nesting: 'offset',        // sideways: every 24px is a level
  indentation: INDENT,
  maxDepth: MAX_DEPTH,
  canNest: (_block, parent) => !!KINDS[parent.kind].nests,
  collapsed: editor.collapsed,
  clone: copyBlock,         // Alt-drag copies a block with what is nested in it
  createItem: (active) => createBlock((active.data as { kind: BlockKind }).kind),
  animateChanges: true,
  overOverlay: BlockGhost,
  dropzone: sheet,
  onDrop, // see Undo, below
});

nesting: 'offset' は、階層をドラッグの横方向のオフセットだけから決めます:ドキュメントエディターと同じく、段落の中央にホバーしてもその中にはドロップされません。canNest は、どのブロックがネストしたブロックを受け入れるかを決め(段落、リスト、To-do、トグル、引用、コールアウト。見出し、コード、画像は受け入れません)、上のブロックが拒否した場合、ラインは条件を満たす最も深い階層に留まります。閉じたトグル(collapsed)もブロックを受け入れます:その下で 1 階層内側にあるラインは、ブロックをトグルの中の先頭に入れます。

各行は自分の indicator から、ドロップが着地する階層に合わせてラインを描きます。番号付きリストは items から数えるので、「2.」や「a.」はドロップや Tab のたびに追従します。animateChanges: true は、ドラッグ以外による変化(Enter、Tab、Delete、Undo、トグルの開閉)もスライドやフェードで表示します。

BlockRow.vue
const { indicator, selected } = makeSortableItem(el, {
  id: () => props.node.key,
  handle: '.handle',
  // A mouse press on a handle drags at once; after 4px instead, a click stays a click.
  activation: { mouse: { distance: 4 } },
  // Enter opens the block's menu; Space picks the block up.
  canDrag: (event) => !('key' in event && event.key === 'Enter'),
});

const line = computed(() => {
  const at = indicator.value;
  if (!at || at.side === 'inside') return null;
  return { side: at.side, left: `${((at.depth ?? 0) - props.node.depth) * props.indent}px` };
});

複数を選択する

ページは選択エリアです:余白から描いた枠が触れたブロックを選択します。strategy: 'select' で空いた余白をクリックすると選択が解除され、accept は枠の対象をブロックに限ります。ハンドルをクリックするとそのブロックが選択され、Shift クリックで範囲を、Ctrl/Cmd クリックでもう 1 つを選択します。テキスト中で Escape を押すと、そのブロックが選択されます。そして:

Page.vue
const { isSelecting, style } = makeSelectionArea(scroller, {
  strategy: 'select',
  accept: 'blocks',                     // the rows' type: their group's name
  disabled: () => menu.value !== null,  // a press closes the menu first
});

選択したブロックの 1 つをドラッグすると、ほかのブロックもついてきます:ツリーはそれらを、それぞれのネストした中身とともに、読む順に 1 つのかたまりとして動かします。Delete で削除し、Ctrl/Cmd+D で複製し、ハンドルのメニューで別の種類に変換します。矢印キーはハンドルからハンドルへ移り、Shift で選択に加えます。

メニューからの新しいブロック

余白の「+」とブロック内の「/」は同じメニューを開きます。クリックすると下にブロックを追加します。各項目は通常のドラッグ可能な要素でもあり、ツリーの createItem が、ドロップされた場所でそれを新しいブロックに変えます。幅の広い画面のサイドバーにも同じ項目が並び、そこでは Space でキーボード用に持ち上げられます:ページに向かう矢印キーで取り込まれます。

InsertOption.vue
makeDraggable(el, {
  id: () => `new-${props.kind}${props.option ? '-option' : ''}`,
  type: 'new-block',
  data: () => ({ kind: props.kind }),
  attributes: () => !props.option, // in the menu, an option of its listbox
});

項目が運ばれている間、メニューはフェードアウトして行き先のページが見えるようになり、新しいブロックが着地すると閉じます。

Page.vue
useDnDMonitor({
  onDragStart: ({ active }) => (lifted.value = !!popover.value?.contains(active.element)),
  onDragSettle: () => {
    if (!lifted.value) return;
    lifted.value = false;
    menu.value = null;
  },
});

ドロップで何が起きるか

BlockGhost.vue はツリーの overOverlay です:ドラッグがページの上にある間、ドラッグ自身のオーバーレイ(<slot />。ブロックか項目のクローン)を件数とメモで包みます。to.parentKey はドロップが入るブロックを示し、mode はコピーのときにそれを伝えます。

BlockGhost.vue
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; mode: DragMode; to: DropDestination | null }>();

// A new block: a kind dragged from the menu or the sidebar, by its `type`.
const adding = computed(() => props.items[0]?.type.includes('new-block'));

const note = computed(() => {
  const verb = props.mode === 'copy' ? 'Copy' : adding.value ? 'Add' : null;
  const parent = props.to?.parentKey == null ? undefined : editor.get(String(props.to.parentKey));
  if (!parent) return verb; // a plain move at the page's top level needs no words
  return `${verb ? `${verb} into` : 'Into'} “${parent.text}”`; // "Copy into “Open questions”"
});
</script>

<template>
  <div class="ghost">
    <slot />
    <span v-if="items.length > 1" class="count">{{ items.length }}</span>
    <span v-if="note" class="note">{{ note }}</span>
  </div>
</template>

Undo

ツリーは各ドロップを書き込み可能な ref である editor.blocks に書き込みます。その onDrop は書き込みの直前に実行されるので、ページを元の状態のまま記録し、Undo でドロップを取り消せます。ほかのすべての編集も同じように自分を記録します。1 つのブロックに入力したキー入力は 1 つのステップになります。

Page.vue
onDrop: (event) => {
  editor.record(); // the library writes the drop after this: Undo takes it back
  const first = event.items[0]!.id;
  if (event.mode === 'insert') editor.focus(first, 'start');
  else editor.focusHandle(first);
},
editor.ts
/** Called before every change: the state it changes goes on the undo stack. */
const record = () => {
  past.value = [...past.value.slice(-99), capture()];
  future.value = [];
  typing = null;
};

履歴は id 付きのブロックのコピーを持つので、Undo と Redo をまたいでもブロックは自分の行と選択を保ちます。ドロップの後、新しいブロックはキャレットを受け取り、移動したブロックはハンドルにフォーカスを渡します:行がページ内で移動するとフォーカスが外れるため、ハンドルからショートカットを使い続けられるようにしています。

キーボードとスクリーンリーダー

テキスト中で Escape を押すと、ブロックが選択され、そのハンドルにフォーカスが移ります。そこから Space で持ち上げ、矢印キーでラインを動かし(左右は 1 階層外へ・内へ)、Space でドロップします。Enter でメニューを開き、Delete で削除します。プロバイダーの announcements はブロックを種類とテキストで読み上げ(「Picked up to-do list “Freeze the API”」)、トップバーのステータス行はボタンとショートカットが何をしたかを読み上げます。

さらに読む:ツリー · マルチドラッグと選択 · 通常のドラッグ可能な要素をリストへ · オーバーレイを変えるターゲット · 押下がドラッグになるとき · アクセシビリティ