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

Selection

Read and set the editor selection, a value that is a caret or range, one inline block, or a set of blocks, and stage pending marks.

The selection is a value stored on edytor.selection. It is independent from the browser’s DOM selection: the editor derives it from what the user does, and draws it back into the page after each render. Read it to build toolbars and menus; set it to move the caret from code.

The selection value

edytor.selection.value is one of four shapes:

type SelectionValue =
	| { kind: 'none' }
	| { kind: 'text'; anchor: DocAnchor; focus: DocAnchor; pending?: Record<string, unknown> }
	| { kind: 'atom'; blockId: string; atomId: string; from: 'before' | 'after' }
	| { kind: 'blocks'; ids: readonly string[] };
Kind When
none Nothing is selected.
text A caret (anchor equals focus) or a text range, possibly across blocks. anchor is where it started, focus where it ends.
atom One inline block is selected, for example after clicking a mention. from is the side the selection was made from.
blocks Whole blocks are selected, for example after clicking a block handle or pressing Mod+A repeatedly.

Text anchors (DocAnchor) are bound to characters, not to numeric offsets. When a collaborator types before your caret, your caret stays next to the same character. They are plain JSON, so you can store or send them.

The value is reactive, so $derived(edytor.selection.value.kind) updates with the selection.

Reading positions: state and projection

Anchors are not offsets. For offsets, read one of the two views computed from the value at the current document version. Both are reactive and read-only.

selection.projection speaks in block ids and block offsets:

Field Meaning
start, end { block, offset } in document order, or null. An inline block counts as 1 in the offset.
isCollapsed, isReversed A caret; a selection made backwards.
blocks Ids of the blocks from start to end, in document order. For a block selection this spans the whole range; use state.blocks for exactly the selected blocks.
isAtStartOfBlock, isAtEndOfBlock, isAtStartOfText, isAtEndOfText Edge checks for the start point.
isTextSpanning, isBlockSpanning The range crosses text segments; crosses blocks.
islandRoot, voidRoot The id of the island or void block the selection is in, or null.
marks The marks at the caret (those of the character before it), or at the start of a range.
content The selected text, as a string.

selection.state speaks in handles, which is what handle commands take:

Field Meaning
startBlock, endBlock Block handles at each end.
startText, endText Text handles at each end.
yStart, yEnd Offsets inside startText and endText.
texts, blocks Every text segment and block the selection covers. For a blocks value, blocks are exactly the selected blocks.
isCollapsed, isReversed, isBlockSpanning As in projection.
const { startText, yStart, isCollapsed } = edytor.selection.state;
if (isCollapsed && startText) startText.insertText({ value: '→ ', start: yStart, end: yStart });

const bold = edytor.selection.projection.marks.bold === true;

Setting the selection

The setters take handles and offsets inside a text segment:

const block = edytor.idToBlock.get('intro');

// A caret at the end of the block's text.
const text = block?.lastText;
if (text) edytor.selection.setAtTextOffset(text, text.length);

// A range over the whole block, or part of it (offsets in its first and last shown lines).
edytor.selection.setAtBlockRange(block);
edytor.selection.setAtBlockRange(block, 0, 5);

// A range between two texts, possibly in different blocks.
edytor.selection.setAtRange(startText, 2, endText, 4, { isReversed: false });

// Whole blocks; with no argument, leave block selection for a text range.
edytor.selection.selectBlocks(blockA, blockB);
edytor.selection.addBlockToSelection(blockC);
edytor.selection.removeBlockFromSelection(blockA);

setAtBlockRange spans what the block shows: for a list, from the start of its first item to the end of its last; for a code block, its lines. A divider shows no line, so the selection stays as it was. Leaving block selection with selectBlocks() gives the range from the set’s first shown line to its last; a divider at either edge stays inside it (the range starts at the end of the line before it, or ends at the start of the line after it). A divider that starts or ends the document has no line beyond it, so it is left out: over [paragraph, divider] the range spans the paragraph alone. So is one with only dividers beyond it: over [paragraph, divider, divider] too, the range spans the paragraph alone.

No caret or range end rests in a block that shows no content of its own (a divider, a list, a code block): a setter, select or a peer’s change that would put one there moves it to the nearest shown line. A caret goes to a list’s first item, else the next line, else the end of the line before. A range’s start goes to the block’s first shown line and its end to its last (a list’s first and last items); at a divider both pass on as a caret does. With no line anywhere, the selection stays as it was.

selection.select(value) sets any value directly. Build text anchors with edytor.facade.anchorAt(blockId, offset, affinity). Bind a range start to the 'right' and its end or a caret to the 'left', so text typed at the boundary stays outside the range:

const anchor = edytor.facade.anchorAt(blockId, 0, 'right');
const focus = edytor.facade.anchorAt(blockId, 5, 'left');
if (anchor && focus) edytor.selection.select({ kind: 'text', anchor, focus });

edytor.selection.select({ kind: 'blocks', ids: [blockId] });
edytor.selection.select({ kind: 'none' });

Showing it

Setters change the value immediately. The browser selection is drawn after the next render, and only when the editor has focus (or nothing else does). When you set the selection from outside the editor, such as a menu button, focus the editor too:

edytor.selection.setAtTextOffset(text, 0);
edytor.node?.focus({ preventScroll: true });

Block selection

A block selection is exactly its members:

  • Clicking a block handle selects that block, not its children. A list and a code block are the exception, as they show nothing but their items or lines: a selected list stands for all of its items, as its highlight shows. Deleting, cutting, copying, pasting or typing over it, Turn into and the formatting keys act on the list with its items, and after Turn into the converted items stay selected. Moving, duplicating and the block menu’s Copy link take it as one block, whole.
  • Shift+↑/↓ from a selected block adds each block it passes, nested blocks included.
  • Mod+A selects the text of the current block, then the block, then every block in the document.
  • Escape leaves block selection and puts the caret at the end of the first selected block’s line (first in document order; for a list, its last shown line; for an image, its caption; a divider passes to the next selected block). Over selected dividers alone it goes to the start of the next line, else to the end of the line before; with no line anywhere they stay selected. Enter and Shift+Enter do the same, as in Notion: they remove nothing (over selected dividers alone, they add a line after them). Typing, a composition or a paste replaces the selected blocks: a composition (Japanese, Chinese or Korean input) starts in the one empty block that takes their place (a paragraph; inside a list, an item), and writes nowhere else.
  • Formatting keys (Mod+B and the others) format exactly the selected blocks’ own text, a selected list’s or code block’s items included, and keep them selected. The hidden body of a closed toggle stays as it is, even inside a selected list; deleting a selected closed toggle keeps its body too, while deleting a selected list takes everything in it.
  • The navigation keys (Home/End, the word keys, PageUp/PageDown, and on macOS Ctrl+A/E) read a block selection from its first shown line’s start to its last shown line’s end: each selected block’s own line, never a nested child that is not selected; for a block with no line of its own, such as a list or a code block, its first or last nested line. Going back the caret lands at the start, going forward at the end, and with Shift the selection grows from that edge in the key’s direction (Ctrl+A/E have no Shift variant). Ctrl+B/F leave a block selection as it is. Over selected dividers alone they keep the dividers selected, except PageUp/PageDown (and Mod+↑/↓), which go to the document’s start or end.
  • Deleting or cutting every block leaves the caret in the emptied document’s paragraph; when only blocks without a line (a divider) are left, the one beside the deleted blocks is selected.
  • Deleting a selection removes only the selected blocks, a selected list or code block with everything in it. A selected parent’s unselected children move into its place (undo puts them back). The caret goes to the start of the first child that takes their place, else to the end of the nearest line before the deleted blocks, or to the start of the nearest line after them when nothing comes before. The block menu’s Delete puts it in the same place.
  • In a code block, the first Mod+A selects the code’s text; deleting or cutting it leaves one empty line in the code block.
  • Copying a block selection copies only the selected blocks, a selected list or code block with everything in it.

Selected blocks have the data-edytor-selected attribute. edytor.selection.selectedBlocks is a reactive Set<Block> of the blocks as clicked: a grip-selected list is one block there. edytor.selection.selectedMembers is what a command over the selection acts on, in document order: the selected blocks, a selected list or code block with its whole subtree (hidden toggle bodies included). Pass it to your own commands, such as edytor.deleteBlocks({ blocks: edytor.selection.selectedMembers }). focusedBlocks holds the blocks a text selection is in (data-edytor-focused).

Pending marks

With a caret, formatting has nothing to apply to yet. The marks you toggle are staged on the caret as pending marks, and the next text typed there takes them:

edytor.selection.pending; // e.g. { bold: true, color: 'red' }, or undefined

// Stage the complete set of marks the next insertion gets.
edytor.selection.stage({ ...edytor.selection.projection.marks, italic: true });

// Clear them.
edytor.selection.stage(undefined);
  • Pending marks are the full set the next insertion takes, values included (links, colors), not a diff.
  • Moving the caret clears them; inserting text at the caret consumes them.
  • stage only applies to a text value.

With the rich text plugin, Mod+B at a caret stages bold this way. richTextOperations(edytor).setMarkAtRange('bold') does the same from code.

Reacting to changes

onSelectionChange (on the component or in a plugin) runs when the value changes. A remote edit that shifts where your selection is shown does not change the value, so it does not fire:

<Edytor
	onSelectionChange={(selection) => {
		toolbarVisible = selection.value.kind === 'text' && !selection.projection.isCollapsed;
	}}
/>

Each view publishes its selection to the document’s awareness, and other people’s selections render as colored carets and highlights. See Collaboration.

Was this page helpful?