Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Block handles

The block handles plugin adds a + button and a drag grip beside each block, with drag and drop, keyboard moves, a drop indicator and an activation hook for block menus.

Block handles are the two buttons left of each block, as in Notion: a + that adds a block, then the ⋮⋮ grip. Drag the grip to reorder, nest or outdent a block; focus it and use Alt with the arrow keys to move it; click it to select the block and open a block menu, such as the bundled block menu. <Edytor> includes them by default.

Enabling and configuring

The blockHandles prop of <Edytor> controls the plugin:

<script lang="ts">
	import { Edytor, type BlockHandleActivation } from 'edytor';

	const openMenu = ({ block, anchor }: BlockHandleActivation) => {
		console.log('open a menu for', block.id, 'next to', anchor);
	};
</script>

<!-- Default: handles with drag and drop. -->
<Edytor />

<!-- No handles at all. -->
<Edytor blockHandles={false} />

<!-- Handles and keyboard moves, no pointer drag, and a click callback. -->
<Edytor blockHandles={{ draggable: false, onActivate: openMenu }} />
PropType
blockHandles?boolean | BlockHandlesOptions

false omits the handles. An object configures them.

Typeboolean | BlockHandlesOptions
Defaulttrue
draggable?boolean

false keeps the handle and its keyboard moves, but disables pointer dragging and drop targets.

Typeboolean
Defaulttrue
onActivate?({ block, anchor }: BlockHandleActivation) => void

Called when a grip is clicked, after the block is selected (a block selection that already holds it stays). anchor is the grip element, to position a menu against. Without it, the click dispatches an edytor-block-activate event instead (see below).

Type({ block, anchor }: BlockHandleActivation) => void
handle?Snippet<[BlockHandleSnippetPayload]>

Replaces the + and the grip with your markup. See Custom handle below.

TypeSnippet<[BlockHandleSnippetPayload]>

The configuration is read when the editor mounts. blockDnd={false} is a deprecated alias of blockHandles={false}; blockHandles wins when both are set.

You can also build the plugin yourself and list it in plugins: blockHandlesPlugin is the default, createBlockHandlesPlugin(options) takes the same options as the prop. The editor recognizes either one and does not add a second set of handles. When blockHandles is an object, it replaces any handle plugin in the list.

Handles

A handle sits in the editor’s overlay layer, left of its block, aligned with the block’s first line of text. For an island block it aligns with the block’s header (a direct child marked use:block.void, such as the code block’s header), and for a void block with the top of the block. Handles follow layout changes, moves and scrolling.

  • A handle exists only for blocks that can move, and is hidden while the editor is readonly.
  • Handles mount lazily, for blocks within about one screen of the viewport and for blocks that are hovered, selected, focused or being dragged. Large documents do not pay for a handle per block.
  • A handle is transparent until you hover its block. On touch devices (no hover), handles are always visible.
  • The buttons are gray (#ada9a3) with a 6px-rounded hover background.

The + button

Clicking + inserts a new block of the parent’s default kind below the block (above with Alt), puts the caret in it and types /, so the slash menu opens there. When the block is already an empty block of that default kind, it takes the / itself instead of adding another. The button does nothing in a readonly editor.

Custom handle

A handle snippet replaces the + and the grip of every handle. The plugin keeps placing it beside the block’s first line, showing it on hover and mounting it lazily. The snippet receives a BlockHandleSnippetPayload:

PropType
block?Block

The block's handle.

TypeBlock
grip?action

use:grip makes an element the grip: dragging (when draggable), a click that selects the block and opens its menu, and the Alt+arrow moves while it has focus.

Typeaction
add?(above?: boolean) => void

What + does: a new block below (above with true), opened on the slash menu.

Type(above?: boolean) => void
readonly?boolean

The editor is readonly: hide your buttons.

Typeboolean
draggable?boolean

Pointer dragging is on.

Typeboolean
<script lang="ts">
	import { Edytor, type BlockHandleSnippetPayload } from 'edytor';
</script>

{#snippet handle({ block, grip, add, readonly }: BlockHandleSnippetPayload)}
	{#if !readonly}
		<button onmousedown={(event) => event.preventDefault()} onclick={(event) => add(event.altKey)}>+</button>
		<button use:grip aria-label={`Move ${block.type} block`}>⠿</button>
	{/if}
{/snippet}

<Edytor blockHandles={{ handle }} />

The same snippet works with createBlockHandlesPlugin({ handle }) in plugins. Your markup renders inside the handle’s host, [data-edytor-block-handle-host] (a flex row right-aligned to the block’s left edge, which carries the hover fade), without the buttons’ built-in styles. Keep preventDefault on the + button’s mousedown, so the click does not move the caret first.

Drag and drop

Drag and drop uses Atlassian’s Pragmatic drag and drop. While you drag, each block is a drop target split in three bands by the height of its own row (not its children):

Pointer position Drop
Top quarter Before the block
Middle Inside it, as its last child
Bottom quarter After the block
Left edge of a nested block (20px) Beside its parent, one level out

Only placements the document allows are offered: when “inside” is not allowed, the block splits between before and after. A short gap between blocks keeps the last valid placement, so the indicator does not flicker.

Dragging the handle of a block that is part of a block selection of siblings moves the whole group, in document order. A selected block’s selected descendants (a Shift + ↓ selection past a parent holds its children) ride with it rather than counting as siblings. After a drop, the moved blocks are selected. Each drop is one undo step.

The drop indicator

The indicator is Notion’s plain 4px bar, drawn in the overlay rather than on the block, so rounded or padded block styles cannot bend it:

  • Before or after: the bar spans the blocks at the drop slot. Between two siblings it is centered in the gap and spans both blocks, so hovering the bottom of one block and the top of the next shows the same bar.
  • Inside: the same bar where the new child will land (after the target’s last visible child, else after its own row), indented to the child’s column and running to the target’s right edge.

Set its color with --edytor-drop-indicator-color on the editor or any ancestor of the blocks (the default is Notion’s rgba(35, 131, 226, 0.43)):

[data-edytor] {
	--edytor-drop-indicator-color: #2eaadc;
}

Keyboard moves

While a handle has focus, Alt with an arrow key moves its block:

Keys Move
Alt + ↑ Up: before the previous sibling, or before the parent from a first child
Alt + ↓ Down: after the next sibling, or after the parent from a last child
Alt + → In: the last child of the previous sibling
Alt + ← Out: after the parent

The handle claims every Alt+arrow while it has focus, and the moved block stays selected. To move the caret’s block from the text, or a block selection, see Arrow move.

Building a block menu

The block menu plugin is a complete Notion-style menu: list blockMenuPlugin and leave onActivate unset. When no onActivate is passed, a grip click dispatches a DOM event on the editor’s root, which that plugin (or your own) listens for:

import { BLOCK_ACTIVATE_EVENT, type BlockActivation, type Plugin } from 'edytor';

export const myMenuPlugin: Plugin = () => ({
	onEdytorAttached: ({ node }) => {
		const open = (event: Event) => {
			const { block, anchor } = (event as CustomEvent<BlockActivation>).detail;
			// Open a menu for block, next to anchor.
		};
		node.addEventListener(BLOCK_ACTIVATE_EVENT, open);
		return () => node.removeEventListener(BLOCK_ACTIVATE_EVENT, open);
	}
});

To build your own menu instead, pass onActivate. It gives you the block and the grip element; the click has already selected the block. A typical menu converts or moves the block with the editor’s commands and then returns the caret to the text:

import { convertToKind, type BlockHandleActivation } from 'edytor';

const onActivate = ({ block, anchor }: BlockHandleActivation) => {
	const { edytor } = block;
	const rect = anchor.getBoundingClientRect();
	// Show your menu at rect.right, rect.top, listing edytor.kinds and move actions.
	// For example, "Turn into heading 2":
	// `true` puts the caret at the start of the converted block.
	const row = edytor.kinds.find((kind) => kind.id === 'block.heading2');
	if (row) convertToKind(edytor, block, row, true);
	// Or "Move down":
	if (edytor.canMoveBlocks({ blocks: [block], direction: 'down' })) {
		edytor.moveBlocks({ blocks: [block], direction: 'down' });
	}
};

The same edytor.moveBlocks and edytor.canMoveBlocks calls move blocks from anywhere, with direction (up, down, in, out) or with a target and position (before, after, inside). See Commands.

A click leaves a block selection behind, and a DOM range alone does not replace it. To put the caret back in the block after a menu action, call setAtTextOffset once — it replaces the block selection and the projector draws it after the flush — then focus the editor. A kind whose own content is not displayed (a list container) takes the caret in its first child:

const text = block.firstText ?? block.children[0]?.firstText;
if (text) {
	edytor.selection.setAtTextOffset(text, 0);
	edytor.node?.focus({ preventScroll: true });
}

Was this page helpful?