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

Blocks

Define block kinds with a BlockDefinition, render them with snippets, mark chrome as void, and declare presets for menus and markdown shortcuts.

A block kind is a named definition in a plugin’s blocks map. It decides the element the core renders, the markup inside it, how the kind behaves structurally, how it is created from menus, and how it is copied and pasted. For what blocks, content and children are, see Blocks.

A block kind

<script module lang="ts">
	import type { Plugin } from 'edytor';
	import type { BlockSnippetPayload } from 'edytor';

	export const calloutPlugin: Plugin = () => ({
		blocks: {
			tip: {
				snippet: tip,
				element: 'aside',
				presets: [{ label: 'Tip', icon: '💡', keywords: ['hint'] }]
			}
		}
	});
</script>

{#snippet tip({ content, children }: BlockSnippetPayload)}
	<div class="tip-text">{@render content()}</div>
	{#if children}
		<div class="tip-children">{@render children()}</div>
	{/if}
{/snippet}

The core renders <aside data-edytor-block data-edytor-type="tip" …> and the snippet renders inside it. A bare snippet is shorthand for a definition with only snippet: blocks: { tip }.

Options

PropType
snippet?Snippet<[BlockSnippetPayload]>

The markup inside the block element. Without one, the element renders empty. Never rendered for elements that take no content (hr, img, input, br).

TypeSnippet<[BlockSnippetPayload]>
element?string | { tag, attributes? } | ((data) => …)

The element the core renders: a tag, a tag with attributes, or a function of the block's data.

Typestring | { tag, attributes? } | ((data) => …)
Default'div'
viewState?string[]

Attributes of the element the browser or the user own, such as 'open' on a details element. The editor never reverts them.

Typestring[]
rendersContent?boolean

false for a container that renders only its children (a list). Its content slot gets no caret. A development warning fires if the snippet disagrees.

Typeboolean
Defaulttrue
defaultChild?string

The kind a new child of this block takes: Enter inside a child, a split, an island merged out into it.

Typestring
continues?boolean

A list-like kind: Enter at the start or end of a non-empty block opens another block of this kind, and Enter in an empty one ends the run. See Continuing kinds below.

Typeboolean
Defaultfalse
container?boolean

A header over its children (a toggle, callout or quote): Enter at the end of one with children, or of an open `<details>` header, opens a first child. See Containers below.

Typeboolean
Defaultfalse
void?boolean

Not editable as a whole and never merged. Takes no children. Text it renders (a caption) stays editable.

Typeboolean
Defaultfalse
island?boolean

Editable, but structurally sealed: nothing merges across its edge, and blocks cannot be moved in or out.

Typeboolean
Defaultfalse
presets?KindPreset[]

One entry per way to create the kind. Feeds the slash menu, markdown shortcuts and block menus.

TypeKindPreset[]
empty?{ content?, children? }

What a conversion into this kind replaces the block's content and children with. Without it, a conversion keeps them.

Type{ content?, children? }
html?string | ((block, content, children) => string)

Clipboard HTML. A tag wraps the content then the children. Default: <p>content</p> then children.

Typestring | ((block, content, children) => string)
plain?(block, content, children) => string

Clipboard plain text. Default: the content, then the children, one per line.

Type(block, content, children) => string
parse?(element: HTMLElement) => data | undefined

HTML import: this kind's data when a pasted element is this kind. Checked before tag matching.

Type(element: HTMLElement) => data | undefined
transformText?({ text, block, content }) => JSONText[]

Decorate text on render (syntax highlighting). The result is never stored.

Type({ text, block, content }) => JSONText[]
onFocus?({ block }) => void

The text selection entered the block.

Type({ block }) => void
onBlur?({ block }) => void

The text selection left the block, or it left a block selection.

Type({ block }) => void
onSelect?({ block }) => void

The block joined a block selection.

Type({ block }) => void
onDeselect?({ block }) => void

The block left a block selection.

Type({ block }) => void
normalizeContent?({ block }) => (() => void) | void

Fix the block's content at the end of an operation that rewrote it.

Type({ block }) => (() => void) | void
normalizeChildren?({ block }) => (() => void) | void

Fix the block's children at the end of an operation that changed them.

Type({ block }) => (() => void) | void

The roles (void, island, rendersContent, defaultChild) belong to the document once an editor adopts them. Two plugins that declare different defaultChild values for one kind make the editor throw at creation.

The snippet payload

A block snippet receives { block, content, children }:

PropType
block.id?string

The block's id.

Typestring
block.type?string

The kind name.

Typestring
block.data?object

The block's data. Reactive.

Typeobject
block.selected?boolean

Part of a block selection. Reactive.

Typeboolean
block.focused?boolean

Holds the caret or part of a text selection, and is not block-selected. Reactive.

Typeboolean
block.handle?Block

The block's handle, for commands and document reads. Not reactive.

TypeBlock
block.void?action

use:block.void marks an element as non-editable chrome.

Typeaction
content?Snippet

Renders the block's inline content: text, marks and inline atoms.

TypeSnippet
children?Snippet | null

Renders the nested blocks, or null when there are none.

TypeSnippet | null

Read values from block in the template, they update on every change. Run commands through block.handle, for example block.handle.setBlock(...); its getters read the document at call time and do not trigger re-renders.

Render content() once, unless the kind declares rendersContent: false. Render children() in its own element, so nested blocks start on a new row:

{#snippet item({ content, children }: BlockSnippetPayload)}
	<div class="text">{@render content()}</div>
	{#if children}
		<div class="nested">{@render children()}</div>
	{/if}
{/snippet}

Chrome with use:block.void

Anything a snippet renders besides content() and children() is chrome: icons, headers, buttons, checkboxes. Mark it with use:block.void so it is non-editable and the caret never enters it:

{#snippet section({ block, content, children }: BlockSnippetPayload)}
	<header use:block.void>
		<span>Section</span>
		<button type="button" onclick={() => block.handle.removeBlock()}>Remove</button>
	</header>
	<div>{@render content()}</div>
	{@render children?.()}
{/snippet}

The action sets contenteditable="false", data-edytor-void="true" and user-select: none on the element. Native controls inside chrome (buttons, inputs, links) keep their own clicks. The block handle of an island aligns with a direct child marked this way.

Presets

A preset is one way to create the kind. Each preset becomes a row of the kind catalogue, edytor.kinds, which the slash menu, the markdown shortcuts plugin and block menus read.

PropType
labelstring

The menu label.

Typestring
icon?string

A short text icon.

Typestring
keywords?string[]

Extra search terms for menus.

Typestring[]
data?object

The new block's data.

Typeobject
markdown?string[]

Prefixes that convert a block when typed at its start. The last character triggers. The first one is shown as the slash menu hint.

Typestring[]
group?string

The slash menu section. The image and code presets use 'Media'.

Typestring
Default'Basic blocks'

Each row gets a command id: block.<type>, or block.<type>1, block.<type>2 and so on when the kind has several presets. The rich text heading has three presets, so its commands are block.heading1 to block.heading3.

Rows keep registration order: plugins in list order, kinds in the order a plugin declares them, presets in array order. The slash menu shows them in that order, grouped by group, with Basic blocks first.

Convert a block from code with convertToKind, or run the row’s command:

import { convertToKind } from 'edytor';

const row = edytor.kinds.find((kind) => kind.id === 'block.heading2');
const block = edytor.selection.state.startBlock;
if (row && convertToKind(edytor, block, row)) {
	// converted
}

// Or, on the block holding the caret:
await edytor.runCommand('block.heading2');

The command converts every selected block, or every block a text range touches, as one undo step that keeps the selection; a replacing kind converts only the block holding the selection’s start.

A row has the preset’s fields plus id, value (the JSON the block is converted to) and replaces (true when the kind has an empty shape). convertToKind applies only to convertible blocks, those that are neither void nor island nor inside an island, and returns whether it applied. When the conversion replaces the content, the caret moves to the start of the converted block, or of its first child. A replacing kind converts in place only a block that holds nothing (no children, no text but a pending slash query); after text or children, the same command inserts the new block after it instead and leaves the block intact. A kind that renders no content (a divider) cannot hold the caret: the same step adds a block of the parent’s default child kind after it, and the caret lands there. For a menu of conversions that keep the text, filter out row.replaces.

Continuing kinds

Set continues: true on a list-like kind, as the rich text plugin does for bulleted and numbered items, to-dos and toggles. Enter then opens another block of the same kind, with the data of the kind’s first preset (a new to-do starts unchecked because its first preset’s data is { checked: false }, also when Enter at the end of a to-do with children moves them to the new one), and Enter in an empty one ends the run. Enter and Backspace by role has the full rules.

blocks: {
	step: {
		snippet: step,
		continues: true,
		presets: [{ label: 'Step', data: { done: false } }]
	}
}

Containers

Set container: true on a kind whose text is a header over its children, as the rich text plugin does for toggles, callouts and quotes. Enter at the end of one that has children opens a first child of its defaultChild kind, and Enter in the middle of its header moves the text after the caret into that first child, the children staying with the header. A header rendered as <details> does so while open, even without children, and adds a sibling (holding the text after the caret) while closed; the open state is the browser’s (viewState: ['open']), read from the block element. See Enter and Backspace by role.

Backspace at the start

Backspace at the start of a block whose kind has presets and is not its parent’s default child turns the block into that default first, keeping its text and children; the next Backspace merges or moves out. Kinds without presets, such as list-item or codeLine, keep the structural behavior. See Enter and Backspace by role.

Normalization

normalizeContent and normalizeChildren run at the end of an operation’s transaction, inside it, for the blocks the operation touched. normalizeContent runs after operations that rewrite a block’s content (setBlock, adding or removing an inline atom, deleteContentAtRange, a soft line break); plain typing does not trigger it. normalizeChildren runs on the parents whose children changed.

Write directly, or return a function: it runs in the same transaction, and the normalizer runs again on the block until it returns nothing (at most 50 more passes). The work is part of the same undo step, and peers never see the unnormalized state.

Override a snippet

To change only how an existing kind renders, pass a snippet prop named <type>Block to <Edytor>. The kind keeps every other option:

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

<Edytor>
	{#snippet quoteBlock({ content, children }: BlockSnippetPayload)}
		<div class="my-quote">{@render content()}</div>
		{@render children?.()}
	{/snippet}
</Edytor>

The same works for marks (<name>Mark) and inline atoms (<type>InlineBlock). A kind name that is not a valid identifier, such as todo-item, can be passed with a spread: <Edytor {...{ 'todo-itemBlock': todoItem }} />, where todoItem is a snippet declared at the top level of your component.

To replace a kind completely, define a kind with the same name in a plugin listed before the one that defines it.

Complete example: a task kind

A task with a checkbox that writes data.done, a markdown prefix, clipboard forms, and a normalizer that keeps its text on one line:

<script module lang="ts">
	import type { Block, Plugin } from 'edytor';
	import type { BlockSnippetPayload } from 'edytor';

	const toggle = (task: Block) =>
		task.setBlock({ value: { data: { ...task.data, done: !task.data.done } } });

	export const taskPlugin: Plugin = () => ({
		blocks: {
			task: {
				snippet: task,
				element: (data) => ({ tag: 'div', attributes: { 'data-done': data.done ? 'true' : undefined } }),
				presets: [
					{ label: 'Task', icon: '☐', keywords: ['todo', 'check'], data: { done: false }, markdown: ['>> '] }
				],
				html: (block, content, children) =>
					`<li><input type="checkbox"${block.data?.done === true ? ' checked' : ''}>${content}${children}</li>`,
				plain: (block, content, children) =>
					[`${block.data?.done === true ? '[x]' : '[ ]'} ${content}`, children].filter(Boolean).join('\n'),
				parse: (element) => {
					const box = element.querySelector<HTMLInputElement>(':scope > input[type="checkbox"]');
					return element.localName === 'li' && box ? { done: box.checked } : undefined;
				},
				// A task is one line: a line break typed with Shift+Enter is removed.
				normalizeContent: ({ block }) => {
					const text = block.firstText;
					const at = text?.stringContent.indexOf('\n') ?? -1;
					if (text && at !== -1) return () => text.deleteAt(at, 1);
				}
			}
		}
	});
</script>

{#snippet task({ block, content, children }: BlockSnippetPayload)}
	<input
		type="checkbox"
		checked={Boolean(block.data.done)}
		use:block.void
		onchange={() => toggle(block.handle)}
	/>
	<div class="task-text">{@render content()}</div>
	{#if children}
		<div class="task-children">{@render children()}</div>
	{/if}
{/snippet}
[data-edytor-type='task'] {
	display: grid;
	grid-template-columns: 18px minmax(0, 1fr);
	column-gap: 8px;
}
[data-edytor-type='task'] > .task-text,
[data-edytor-type='task'] > .task-children {
	grid-column: 2;
}
[data-edytor-type='task'][data-done='true'] > .task-text {
	text-decoration: line-through;
	opacity: 0.6;
}

Was this page helpful?