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.
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);
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.
- 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.
- Deleting a selection removes only the selected blocks. A selected parent’s unselected children move into its place (undo puts them back).
- Copying a block selection copies only the selected blocks.
Selected blocks have the data-edytor-selected attribute. edytor.selection.selectedBlocks is a reactive Set<Block>; 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.
stageonly applies to atextvalue.
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.