Plite stores selection in the model, then reconciles it with browser-native selection in React. Use this page for caret and DOM rules; use Editing Behavior for the full event-to-commit pipeline.
Selection bugs need the layer that can actually fail.
| Claim | Assert | Owner |
|---|---|---|
| The Plite selection is correct | model selection | @platejs/plite |
| The browser caret lands in the right DOM text | DOM caret or DOM selection | @platejs/plite-react and |
@platejs/plite-dom| The visible browser highlight is not duplicated | displayed selection snapshot | @platejs/browser |
| Hidden content remains selectable or copyable | DOM coverage policy plus model result | @platejs/plite-dom and @platejs/plite-react |
| Follow-up typing still works | text, selection, focus, and commit after the asserted state | @platejs/browser |
Do not close a selection bug with only one layer unless the bug only exists in that layer.
Plite has two selection systems in a React editor:
| Selection | What it means |
|---|---|
| Model selection | The serializable TextSelection, NodeSelection, or null stored by the Plite editor. |
| Native selection | The browser Selection visible in the contenteditable DOM. Only text selection projects to it. |
Most editor code should write the model selection and let Plite export the DOM/native selection. Plite imports the native selection when the browser is the current source of truth, such as user selection movement or native input.
Read selection through editor.read(...).
const selection = editor.read((state) => state.selection());const selection = editor.read((state) => state.selection());Write selection inside editor.update(...).
import { SelectionApi } from "@platejs/plite";
editor.update((tx) => {
tx.selection.set(
SelectionApi.text({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [1, 0], offset: 3 },
})
);
});import { SelectionApi } from "@platejs/plite";
editor.update((tx) => {
tx.selection.set(
SelectionApi.text({
anchor: { path: [0, 0], offset: 0 },
focus: { path: [1, 0], offset: 3 },
})
);
});Use explicit predicates instead of rebuilding point and block comparisons in feature code.
const acrossBlocks = editor.read.selection.isAcrossBlocks();
const atHeadingStart = editor.read.selection.isAtBlockStart({
type: "heading",
});
const intersectsIntro = editor.read.selection.intersects([0]);
const containsRange = editor.read.selection.contains(range);
const atWordEnd = editor.read.points.isWordEnd(point);const acrossBlocks = editor.read.selection.isAcrossBlocks();
const atHeadingStart = editor.read.selection.isAtBlockStart({
type: "heading",
});
const intersectsIntro = editor.read.selection.intersects([0]);
const containsRange = editor.read.selection.contains(range);
const atWordEnd = editor.read.points.isWordEnd(point);isWithinBlock, isAcrossBlocks, isAtBlockStart, and isAtBlockEnd accept
at, type, and match options. Omit at to inspect the current selection.
These queries return false when the target or current selection does not
resolve.
Use Locations for Path, Point, Range, and anchors. Use Transforms for transaction selection helpers.
The browser can move native selection through mouse, keyboard, touch, composition, spellcheck, selection handles, and platform editing commands. Plite React observes those moves and imports them when they should become model state.
Application code should not patch the browser Selection directly for normal editor behavior. Use model transactions, Editable handlers, or DOM-aware React APIs that preserve Plite's repair path.
Use React Editor for DOM translation helpers and Plite React Event Handling for browser event hooks.
Horizontal arrow and word movement are visual DOM operations. Plite React asks the DOM capability for the next physical left or right point and affinity, so mixed-direction text follows the browser's rendered order rather than one block-wide direction guess. Core point transforms remain logical and DOM-free.
const next = editor.api.dom.resolveVisualPoint(point, {
affinity: "forward",
direction: "left",
unit: "word",
});const next = editor.api.dom.resolveVisualPoint(point, {
affinity: "forward",
direction: "left",
unit: "word",
});This low-level API is useful for renderer integrations. Normal editors should
let <Editable> own keyboard movement.
Use a non-void, isolating element with keyboardSelectable: true when one
document node owns both non-editable asset chrome and editable child content.
The model keeps its focus states distinct:
| State | Behavior |
|---|---|
NodeSelection at the owner path | Focuses the asset. The native browser selection stays empty. ArrowDown enters the owner's text content, and Backspace or Delete removes the owner. |
TextSelection inside the owner | Edits child content. ArrowUp from its leading visual boundary returns to the owner NodeSelection. |
Clicking a contenteditable="false" descendant of a keyboard-selectable owner
creates its NodeSelection. Clicking editable child text creates a normal
TextSelection.
import { defineExtension, schema } from "@platejs/plite";
const media = defineExtension("media", {
schema: {
elements: {
image: {
content: schema.content.text({ default: "text", min: 1 }),
isolating: true,
keyboardSelectable: true,
},
},
},
});import { defineExtension, schema } from "@platejs/plite";
const media = defineExtension("media", {
schema: {
elements: {
image: {
content: schema.content.text({ default: "text", min: 1 }),
isolating: true,
keyboardSelectable: true,
},
},
},
});In React node UI, use useElementSelected({ mode: "node" }) for the exact
asset-focused state. The default intersection mode also matches text selection
inside the element.
Each mounted Editable owns one bounded scheduler for post-model browser work.
It runs queued tasks in this order:
Selection export, scroll restoration, native-input repair, drag auto-scroll, projected caret cleanup, focus restoration, and DOM repair share that owner. Semantic browser clocks such as composition guard lifetimes can wait on a timer, but their DOM or selection side effects re-enter the phase scheduler. Recursive scheduling is bounded and recorded as a loop diagnostic instead of spinning indefinitely.
DOM coverage boundaries represent model content whose editable DOM is not mounted.
| Policy | Use it when |
|---|---|
selectionPolicy="skip" | Hidden content is app chrome or should not receive cursor movement. |
selectionPolicy="model" | Hidden content can be selected in the Plite model without mounting DOM. |
selectionPolicy="materialize" | Plite should ask the app to reveal content before moving selection into it. |
Clipboard has its own policy. Model-backed copy can include hidden document content without selecting every hidden DOM node. Use Clipboard And Paste for copy, paste, drop, and fragment import ownership.
Use DOM Coverage Boundaries for selectionPolicy, copyPolicy, findPolicy, and materialization behavior.
Void elements still need a model text anchor so Plite can place selection around them. Editable islands keep an element void for outer rendering policy while allowing cursor projection into its text children.
import { defineExtension, schema } from "@platejs/plite";
const media = defineExtension("media", {
schema: {
elements: {
"captioned-image": {
content: schema.content.text({ default: "text", min: 1 }),
void: "editable-island",
},
},
},
});import { defineExtension, schema } from "@platejs/plite";
const media = defineExtension("media", {
schema: {
elements: {
"captioned-image": {
content: schema.content.text({ default: "text", min: 1 }),
void: "editable-island",
},
},
},
});Install this feature contribution beside the editor's complete schema.
Keep visible void UI inside the void renderer. Let Plite render the shell and text anchor. Use Editable Component for void rendering rules.
Large documents may not have a complete DOM mounted at every moment. Editable can use DOM strategies such as auto, staged, full, or virtualized to keep the active editing surface responsive.
That changes what native DOM can prove. A model selection can cover the full document while the mounted DOM only covers the active window or materialized boundary.
Use Editable Component for domStrategy and Virtualized Rendering for the experimental lane.
Selection proof should assert the layers named by the claim.
import { openExample } from "@platejs/browser/playwright";
const editor = await openExample(page, "plaintext", {
ready: { editor: "visible" },
});
await editor.focus();
await editor.selectAll();
await editor.type("Hello");
await editor.assert.selection({
anchor: { path: [0, 0], offset: 5 },
focus: { path: [0, 0], offset: 5 },
});
await editor.assert.domCaret({ text: "Hello", offset: 5 });
await editor.assert.noDoubleSelectionHighlight();
await editor.type("!");
await editor.assert.text("Hello!");import { openExample } from "@platejs/browser/playwright";
const editor = await openExample(page, "plaintext", {
ready: { editor: "visible" },
});
await editor.focus();
await editor.selectAll();
await editor.type("Hello");
await editor.assert.selection({
anchor: { path: [0, 0], offset: 5 },
focus: { path: [0, 0], offset: 5 },
});
await editor.assert.domCaret({ text:
Use Browser for Playwright helpers that capture model selection, DOM selection, displayed selection, focus, native event traces, clipboard, screenshots, and follow-up typing.
Done. You can now say which selection layer owns the bug before writing the fix or the proof.