Mod+Shift+ArrowUp and Mod+Shift+ArrowDown move the blocks around the caret.UploadPlugin accepts native drops.DndKit adds a handle to each selectable block at any depth, such as blocks inside blockquotes, details, columns and table cells, and shows only the handle of the innermost hovered block. Rows, cells, column items and the details summary get none. It also paints the drop indicator and binds the keyboard moves. Drag and drop uses the browser's native events; it needs no provider or extra dependency.
'use client';
import {
ArrowDownIcon,
ArrowUpIcon,
Columns2Icon,
GripVertical,
type LucideIcon,
ScissorsIcon,
} from 'lucide-react';
import { type Element, type NodeKey, PathApi } from 'platejs';
import {
definePlugin,
type Editor,
type RenderNodeWrapperProps,
type WrapRootProps,
useDropIndicator,
useEditor,
useEditorSelector,
useElementSelected,
} from 'platejs/react';
import { BaseTablePlugin } from 'platejs/table';
import * as React from 'react';
import { createPortal } from 'react-dom';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/editor/dropdown-menu';
/**
* Start a native block drag from a handle and use the dragged blocks as the
* drag image, held at the pointer's offset from the first block.
*/
export const startBlockDrag = (
editor: Editor,
event: React.DragEvent,
element: Element
) => {
const drag = editor.api.dom.drag.start(event.nativeEvent, { node: element });
if (!drag) {
event.preventDefault();
return;
}
const document = event.currentTarget.ownerDocument;
const rect = editor.api.dom.resolveDOMNode(element)?.getBoundingClientRect();
const image = document.createElement('div');
image.style.cssText = `position:fixed;pointer-events:none;left:${rect?.left ?? 0}px;top:${rect?.top ?? 0}px;width:${rect?.width ?? 0}px;`;
image.className = 'flow-root opacity-50';
image.append(...drag.previews);
document.body.append(image);
event.dataTransfer.setDragImage(image, drag.origin.x, drag.origin.y);
// The browser snapshots the image during dragstart.
requestAnimationFrame(() => image.remove());
};
/**
* Cut blocks to the clipboard. They are removed only once the clipboard holds
* them, so a refused clipboard write loses nothing.
*/
export const cutBlocks = async (editor: Editor, keys: readonly NodeKey[]) => {
if (keys.length === 0) return;
editor.update.selection.setNodes(keys);
const data = new Map<string, string>();
editor.api.dom.clipboard.writeSelection({
getData: (type) => data.get(type) ?? '',
setData: (type, value) => {
data.set(type, value);
},
});
const items: Record<string, Blob> = {};
for (const type of ['text/html', 'text/plain']) {
const value = data.get(type);
if (value !== undefined) items[type] = new Blob([value], { type });
}
try {
await navigator.clipboard.write([new ClipboardItem(items)]);
} catch {
return;
}
editor.update((tx) => {
const paths = keys
.flatMap((key) => {
const entry = tx.nodes.get(key);
return entry ? [entry[1]] : [];
})
.toSorted((a, b) => PathApi.compare(b, a));
for (const path of paths) tx.nodes.remove({ at: path });
});
};
/**
* A drag handle's actions. The handle opens it on click, which a drag never
* fires, so the anchor stays out of pointer events.
*/
export function HandleActionsMenu({
actions,
className,
onOpenChange,
open,
style,
}: {
actions: ReadonlyArray<{ icon: LucideIcon; label: string; run: () => void }>;
className?: string;
onOpenChange: (open: boolean) => void;
open: boolean;
style?: React.CSSProperties;
}) {
const editor = useEditor();
return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger tabIndex={-1}>
<span
aria-hidden
className={cn('pointer-events-none absolute', className)}
style={style}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="left">
{actions.map(({ icon: Icon, label, run }) => (
<DropdownMenuItem
key={label}
finalFocus={() => editor.api.dom.focus()}
onSelect={run}
>
<Icon />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function Draggable(props: RenderNodeWrapperProps) {
const { children, editor, element, renderPath } = props;
const [buttonTop, setButtonTop] = React.useState(0);
const [actionsOpen, setActionsOpen] = React.useState(false);
const [besideActions, setBesideActions] = React.useState<
Array<{ icon: LucideIcon; label: string; run: () => void }>
>([]);
const selected = useElementSelected();
// A drag node-selects the handle's blocks, so their gutter stays laid out.
// Mode 'node' matches those blocks only, so with hover the blocks nested
// inside them keep their gutters hidden.
const nodeSelected = useElementSelected({ mode: 'node' });
const hasTableCellSelection = React.useContext(TableCellSelectionContext);
const table = editor.plugin(BaseTablePlugin);
const isTable = table.installed && element.type === table.schema.type;
const nodes = () => editor.read.transfer.nodes({ node: element });
const move = (to: 'next' | 'previous', announce: string) => {
editor.api.transfer.move({ announce, nodes: nodes(), to });
};
// The siblings around the whole payload, such as a list item with its
// nested items, not around the handle's own block.
const besideOf = (keys: readonly NodeKey[]) => {
const paths = keys.map((key) => editor.read.nodes.path(key));
const first = paths[0];
const last = paths.at(-1);
if (
!first ||
!last ||
!PathApi.equals(PathApi.parent(first), PathApi.parent(last))
) {
return [];
}
return [
{
label: 'Move beside previous',
sibling: PathApi.hasPrevious(first) ? PathApi.previous(first) : null,
side: 'end' as const,
},
{
label: 'Move beside next',
sibling: PathApi.next(last),
side: 'start' as const,
},
].flatMap(({ label, sibling, side }) => {
const key = sibling && editor.key(sibling);
if (!key) return [];
const to = { key, side };
return editor.read.transfer.check({ nodes: keys, to }).admitted
? [
{
icon: Columns2Icon,
label,
run: () => {
editor.api.transfer.move({
announce: 'Moved beside',
nodes: keys,
to,
});
},
},
]
: [];
});
};
const openActions = () => {
const keys = nodes();
if (!keys.every((key) => editor.read.selection.contains(key))) {
editor.update.selection.setNodes(keys);
}
setBesideActions(besideOf(keys));
setActionsOpen(true);
};
return (
// Only the innermost hovered block shows its handle. The hovered flag is a
// DOM attribute, not React state, so toggling it re-renders nothing, and a
// nested gutter is display: none until hovered, so a large table lays out
// no gutter per cell block.
<div
className={cn(
'editor-draggable relative data-hovered:[&>.editor-gutterLeft]:opacity-100 [&>.editor-blockWrapper>[data-editor-dragging]]:opacity-50',
// A table keeps its handle while the pointer is anywhere inside it, so
// the handles of its cell blocks do not make it flicker.
isTable && 'hover:[&>.editor-gutterLeft]:opacity-100',
renderPath.length > 1 &&
(isTable
? 'not-hover:[&>.editor-gutterLeft]:hidden'
: 'not-data-hovered:[&>.editor-gutterLeft]:hidden')
)}
onMouseEnter={() => setButtonTop(calcDragButtonTop(editor, element))}
onPointerLeave={(event) => {
event.currentTarget.removeAttribute('data-hovered');
}}
onPointerOver={(event) => {
event.currentTarget.toggleAttribute(
'data-hovered',
(event.target as globalThis.Element).closest('.editor-draggable') ===
event.currentTarget
);
}}
>
{!hasTableCellSelection && (
<Gutter
className={cn(
actionsOpen && 'opacity-100',
// Without hover, a tap's pointerleave clears data-hovered, so
// every block the selection touches keeps its gutter.
selected &&
'[@media(hover:none)]:flex! [@media(hover:none)]:opacity-100',
(nodeSelected || actionsOpen) && 'flex!'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-expanded={actionsOpen}
aria-haspopup="menu"
aria-label="Drag block"
className="pointer-events-auto absolute -left-0 flex h-6 w-4.5 cursor-grab items-center justify-center p-0 text-muted-foreground [:is(td,th)_&]:w-3"
data-editor-prevent-deselect
data-editor-selectable
draggable
style={{ top: `${buttonTop + 3}px` }}
type="button"
onClick={openActions}
onDragStart={(event) => startBlockDrag(editor, event, element)}
>
<GripVertical />
</button>
</TooltipTrigger>
{/* A hidden nested gutter leaves the trigger at 0,0; hide the tooltip
there instead of fading it out in the corner. */}
<TooltipContent hideWhenDetached>
Drag to move, click for actions
</TooltipContent>
</Tooltip>
{actionsOpen && (
<HandleActionsMenu
actions={[
{
icon: ArrowUpIcon,
label: 'Move up',
run: () => move('previous', 'Moved up'),
},
{
icon: ArrowDownIcon,
label: 'Move down',
run: () => move('next', 'Moved down'),
},
...besideActions,
{
icon: ScissorsIcon,
label: 'Cut',
run: () => {
void cutBlocks(editor, nodes());
},
},
]}
className="-left-0 h-6 w-4.5"
open
style={{ top: `${buttonTop + 3}px` }}
onOpenChange={setActionsOpen}
/>
)}
</Gutter>
)}
<div className="editor-blockWrapper flow-root">{children}</div>
</div>
);
}
function Gutter({
children,
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
{...props}
className={cn(
'editor-gutterLeft',
'-translate-x-full absolute top-0 z-50 flex h-full w-[22px] cursor-text select-none hover:opacity-100 sm:opacity-0 [:is(td,th)_&]:w-3',
'focus-within:opacity-100',
className
)}
contentEditable={false}
data-editor-selectable
>
{children}
</div>
);
}
const calcDragButtonTop = (editor: Editor, element: Element): number => {
const child = editor.api.dom.resolveDOMNode(element);
const window = child?.ownerDocument.defaultView;
if (!child || !window) return 0;
return Number(window.getComputedStyle(child).marginTop.replace('px', ''));
};
function DropIndicator() {
const editor = useEditor();
const indicator = useDropIndicator();
if (!indicator) return null;
const { axis, line } = indicator;
return createPortal(
<div
aria-hidden
className="pointer-events-none fixed top-0 left-0 z-50 rounded-full bg-brand/50"
data-drop-indicator={axis}
style={
axis === 'y'
? {
height: 2,
transform: `translate(${line.x}px, ${line.y - 1}px)`,
width: line.width,
}
: {
height: line.height,
transform: `translate(${line.x - 1}px, ${line.y}px)`,
width: 2,
}
}
/>,
editor.api.dom.getWindow().document.body
);
}
const TableCellSelectionContext = React.createContext(false);
/** One table-selection subscription for every handle in the editor. */
function DndRoot({ children }: WrapRootProps) {
// A selected table node also reports its cells; only a text selection
// across cells hides the handles, so a drag's own selection never removes
// its source.
const hasTableCellSelection = useEditorSelector((editor) => {
const table = editor.plugin(BaseTablePlugin);
return (
table.installed &&
editor.read.selection.nodes().length === 0 &&
(table.read.selection()?.cells.length ?? 0) > 1
);
});
return (
<TableCellSelectionContext value={hasTableCellSelection}>
<TooltipProvider>{children}</TooltipProvider>
</TableCellSelectionContext>
);
}
/** Block handles, the drop indicator and the keyboard block move. */
export const DndPlugin = definePlugin('dnd', {
shortcuts: {
moveBlockDown: {
keys: 'mod+shift+arrowdown',
handler: ({ editor }) =>
editor.api.transfer.move({ announce: 'Moved down', to: 'next' })
.status !== 'refused',
},
moveBlockUp: {
keys: 'mod+shift+arrowup',
handler: ({ editor }) =>
editor.api.transfer.move({ announce: 'Moved up', to: 'previous' })
.status !== 'refused',
},
},
slots: {
afterEditable: DropIndicator,
wrapNode: {
component: Draggable,
match: ({ editor, element }) =>
!editor.read.view.isReadOnly() &&
editor.read.schema.isBlockContent(element) &&
editor.read.nodes.isSelectable(element),
},
wrapRoot: DndRoot,
},
});
export const DndKit = [DndPlugin];'use client';
import {
ArrowDownIcon,
ArrowUpIcon,
Columns2Icon,
GripVertical,
type LucideIcon,
ScissorsIcon,
} from 'lucide-react';
import { type Element, type NodeKey, PathApi } from 'platejs';
import {
definePlugin,
type Editor,
type RenderNodeWrapperProps,
type WrapRootProps,
useDropIndicator,
useEditor,
useEditorSelector,
useElementSelected,
} from 'platejs/react';
import { BaseTablePlugin } from 'platejs/table'
import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...DndKit,
],
});import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
const editor = createEditor({
Two EditorRoots over one editor are two views of one document, so a drag between them moves the block. Mount the second view with suppressInstanceWarning. A drop into another editor copies, and the source keeps its blocks.
The copied table and column components add their own row and column handles. Rows land only beside rows of the same table, and the table refuses a drop that would split a merged cell. Columns reorder within their group. Other blocks can drop into a cell or a column.
Drop a block on the inline-end edge of a top-level block, the right edge in left-to-right text, in the middle half of its height, and the indicator turns vertical: the drop places both blocks in a new two-column group. The editor's side padding beside a top-level block does the same over the block's full height. The right padding places the dropped block after it, and the left padding places it before. Padding counts only once the pointer is 20px past where the drag started, so a straight drag down from a handle in the padding still lands above or below. Beside a block inside a column, the drop adds a column to that group, up to five. A group never nests in another group, and a block inside a table cell, blockquote or details body takes no side drop.
The React UploadPlugin places dropped files when nativeDrop is true; the copied Upload kit enables it. A file drop shows the same indicator and lands only where one upload block per file can go. A browser that hides the file count while dragging gets an indicator checked for one block. The drop itself checks every file. A refused drop inserts nothing.
The editor resolves every dragover and drop itself. It finds the landing, publishes the indicator and runs the transfer. A handle only starts the drag, and a component paints the indicator.
import {
definePlugin,
type RenderNodeWrapperProps,
useDropIndicator,
} from 'platejs/react';
function DragHandle({ children, editor, element }: RenderNodeWrapperProps) {
return (
<div className="relative [&>[data-editor-dragging]]:opacity-50">
<button
aria-label="Drag block"
className="absolute -left-6 cursor-grab"
contentEditable={false}
draggable
type="button"
onDragStart={(event) => {
if (!editor.api.dom.drag.start(event.nativeEvent, { node: element })) {
event.preventDefault();
}
}}
>
⠿
</button>
{children}
</div>
);
}
function DropIndicator() {
const indicator = useDropIndicator();
if (!indicator) return null;
const { line } = indicator;
return (
<div
aria-hidden
className="pointer-events-none fixed top-0 left-0 h-0.5 bg-blue-500"
style={{
transform: `translate(${line.x}px, ${line.y - 1}px)`,
width: line.width,
}}
/>
);
}
export const DragHandlePlugin = definePlugin('dragHandle', {
slots: {
afterEditable: DropIndicator,
wrapNode: {
component: DragHandle,
match: ({ editor, element }) =>
editor.read.schema.isBlockContent(element) &&
editor.read.nodes.isSelectable(element),
},
},
});import {
definePlugin,
type RenderNodeWrapperProps,
useDropIndicator,
} from 'platejs/react';
function DragHandle({ children, editor, element }: RenderNodeWrapperProps) {
return (
<div className="relative [&>[data-editor-dragging]]:opacity-50">
<button
aria-label="Drag block"
className="absolute -left-6 cursor-grab"
contentEditable={false}
draggable
type="button"
onDragStart={(event
The match gives every selectable block a handle, at any depth; match renderPath.length === 1 for top-level blocks only. drag.start returns null when nothing can be dragged, such as a removed block. While the drag runs, every dragged block carries data-editor-dragging, which the wrapper above dims. A read-only view starts a copy-only drag.
The indicator moves on every dragover, so position it with transform, which skips layout. Column items lay out horizontally, so their indicator has axis: 'x'. Paint a vertical line from line.x, line.y and line.height.
drag.start also returns inert clones of the dragged blocks and the pointer's offset from the first one. The kit's startBlockDrag places them over the dragged block at half opacity, with no background, and hands them to the browser as the drag image:
onDragStart={(event) => {
const drag = editor.api.dom.drag.start(event.nativeEvent, { node: element });
if (!drag) {
event.preventDefault();
return;
}
const rect = editor.api.dom.resolveDOMNode(element)?.getBoundingClientRect();
const image = document.createElement('div');
image.style.cssText = `position:fixed;pointer-events:none;left:${rect?.left ?? 0}px;top:${rect?.top ?? 0}px;width:${rect?.width ?? 0}px;`;
image.className = 'flow-root opacity-50';
image.append(...drag.previews);
document.body.append(image);
event.dataTransfer.setDragImage(image, drag.origin.x, drag.origin.y);
// The browser snapshots the image during dragstart.
requestAnimationFrame(() => image.remove());
}}onDragStart={(event) => {
const drag = editor.api.dom.drag.start(event.nativeEvent, { node: element });
if (!drag) {
event.preventDefault();
return;
}
const rect = editor.api.dom.resolveDOMNode(element)?.getBoundingClientRect();
const image = document.createElement('div');
image.style.cssText = `position:fixed;pointer-events:none;left:${rect?.left ?? 0}px;top:${rect?.top ?? 0}px;width:${rect?.width ?? 0}px;`;
Only the faded blocks follow the pointer. A block that paints its own background, such as a code block or a table cell, keeps that background at half opacity.
editor.api.transfer runs the same rules as a drop. Each call is one undo step and returns its outcome.
const outcome = editor.api.transfer.move({
nodes: [editor.key(element)],
to: { edge: 'after', key: editor.key(target) },
});
if (outcome.status === 'refused') {
console.warn(outcome.reason);
}const outcome = editor.api.transfer.move({
nodes: [editor.key(element)],
to: { edge: 'after', key: editor.key(target) },
});
if (outcome.status === 'refused') {
console.warn(outcome.reason);
}to also accepts 'next' and 'previous', which step the blocks past their nearest sibling inside their parent and keep the selection; blocks under different parents refuse with policy. Without nodes, the transfer carries the blocks editor.read.transfer.nodes() returns. Pass announce to give screen readers a message when the move lands:
editor.api.transfer.move({ announce: 'Moved down', to: 'next' });editor.api.transfer.move({ announce: 'Moved down', to: 'next' });Pass from with another editor to move or copy from it. Between independent editors a move always copies.
to also accepts a block's side, { key, side: 'start' | 'end' }, which places the blocks beside it when a feature builds side landings, such as the Column plugin. A side takes only a move inside one document; a copy refuses with policy. Ask before offering the action, as the handle menu does:
const to = { key: editor.key(previous), side: 'end' } as const;
if (editor.read.transfer.check({ nodes: [editor.key(element)], to }).admitted) {
editor.api.transfer.move({ announce: 'Moved beside', nodes: [editor.key(element)], to });
}const to = { key: editor.key(previous), side: 'end' } as const;
if (editor.read.transfer.check({ nodes: [editor.key(element)], to }).admitted) {
editor.api.transfer.move({ announce: 'Moved beside', nodes: [editor.key(element)], to });
}A block lands wherever the target's schema accepts it. A plugin refuses what its schema allows but the feature does not, such as a row in another table, with a transferVeto contribution. It runs on the final edge for every drag, keyboard move and editor.api.transfer call, after features such as lists have adjusted it.
import { transferVeto } from 'platejs';
import { definePlugin } from 'platejs/react';
export const PinnedPlugin = definePlugin('pinned', {
contributions: [
transferVeto.of(({ edge, target: [node] }) => edge === 'before' && node.pinned === true),
],
});import { transferVeto } from 'platejs';
import { definePlugin } from 'platejs/react';
export const PinnedPlugin = definePlugin('pinned', {
contributions: [
transferVeto.of(({ edge, target: [node] }) => edge === 'before' && node.pinned === true),
],
});The input also carries the source view (from), the intent (move or copy), the payload and the documents' relation. The second argument is the target view.
To move an edge instead, such as past a list item's nested items, wrap editorReads.transfer.landing in read middleware and return another edge, or next() to keep it. A moved edge must map to itself; the schema and the vetoes then check the final edge.
A drop at a block's side lands only when a feature describes what to build there through editorReads.transfer.side. The Column plugin returns a column group that takes the target's place:
import { editorReads } from 'platejs';
around(editorReads.transfer.side, ({ input, next }) =>
input.target[1].length === 1
? {
payload: [input.side === 'end' ? 1 : 0],
shell: {
children: [
{ children: [], type: 'column', width: '50%' },
{ children: [], type: 'column', width: '50%' },
],
type: 'columnGroup',
},
target: [input.side === 'end' ? 0 : 1],
}
: next()
),import { editorReads } from 'platejs';
around(editorReads.transfer.side, ({ input, next }) =>
input.target[1].length === 1
? {
payload: [input.side === 'end' ? 1 : 0],
shell: {
children: [
{ children: [], type: 'column', width: '50%' },
{ children: [], type: 'column', width: '50%' },
],
type: 'columnGroup',
},
target: [input.side === 'end' ? 0 : 1],
With target, the shell takes the target's place and the target moves into that slot. Without it, return ancestor and edge to land the shell before or after the target, or its ancestor that many levels up, as Column does to add a column beside a block's column. The dragged blocks move into the slot at payload, all in one undo step. A target whose feature family reaches past it, such as a list item with nested items, refuses. The schema checks the filled shell and each slot, and every veto runs with wrap set; a wrap that takes the target's place also runs the vetoes with the target as the payload.
Takes the same input as move and keeps the source.
editor.read.transfer.check(input: TransferInput): TransferCheck admits a move without running it, through the same schema, redirects and vetoes the drop indicator paints from. It returns { admitted: true, to } or { admitted: false, reason }. A refusal found only at commit, such as a lossy landing, is not predicted.
editor.read.transfer.nodes(options?: { node?: Element | NodeKey }): NodeKey[] returns the blocks a transfer carries, such as a list item with its nested items. With node, it returns a handle's blocks: the selected blocks when the selection holds node, else node alone. Without it, it returns the selected blocks, or the blocks containing the selection. Pass the result as nodes to editor.api.transfer.move.
useDropIndicator(): DropIndicator | null reads the current view's drop indicator.
editor.api.dom.drag.start, editor.api.dom.drag.indicate and editor.api.dom.resolveDropTarget drive a drag from code, such as a custom handle that is not a native draggable.