Marks API: range-aware inline formatting
Range-aware inline-mark operations for building inline formatting tools. selection.findParentTag inspects only the selection's two boundary nodes (anchor and focus) and their ancestors. api.marks works on the WHOLE range instead.
- has answers "is every text node in the selection covered".
- apply and remove split partially-covered wrappers at the range boundaries, update fully-covering wrappers in place, and restore the selection afterwards.
- apply and remove also extend the range over trailing whitespace that browsers exclude from double-click selections.
A MarkSpec describes a mark declaratively: tag, aliasTags, className, attributes, style. aliasTags lets legacy tag variants match as the SAME mark, for example <b> next to <strong>, or <em> next to <i>. New wrappers always use the canonical tag.
String values are static and take part in the mark's identity. Function-form values are resolved from the state passed to apply/toggle, and are deliberately EXCLUDED from identity. That is what makes a colour picker ONE mark that updates in place, rather than N mutually-cancelling marks.
Two specs sharing tag, classNames and static attributes belong to the same family. They compose on a single element. A text-colour spec and a background-colour spec can both sit on one <mark>.
Every method defaults to the live selection's first range when no range is passed.
The core export markSanitizerConfig(spec) derives the sanitizer rule a mark produces. It allowlists the spec's tag, strips style properties and classes the spec does not declare, and keeps declared attributes. Function-form values are handled by property name, so dynamic values are never dropped on save.
The React adapter's createReactInlineTool applies the same derivation automatically when a tool declares a mark spec.
Reaching the editor instance
The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;
// Call any API method on it.
editor.caret.setToLastBlock('end');Methods
marks.has(spec, range?)
booleanWhether every text node in the range is inside a wrapper matching the spec. Whitespace-only text nodes are ignored. At a collapsed caret, the caret's ancestors are checked.
selection.findParentTag checks only the selection's boundary nodes and their ancestors. This method is stricter: a selection that only partially carries the mark reports false.
When to use
The range-aware replacement for the findParentTag activity check. A selection that only partly carries the mark reports false, so your toolbar icon cannot lie.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
spec | MarkSpec | Required | — | Declarative mark description: tag plus optional className, attributes, and style. |
range | Range | — | current selection | Range to check. Defaults to the live selection's first range. |
const highlight = { tag: 'span', className: 'my-highlight' };
// True only when the WHOLE selection is covered — a half-highlighted
// selection reports false, so a toolbar icon cannot lie
const active = editor.marks.has(highlight);marks.find(spec, from?)
HTMLElement | nullNearest ancestor element matching the spec, starting from the given node (or the current selection's start container). Matching respects the full spec, not just the tag name: tag, classNames, and static attribute/style values.
When to use
Ancestor lookup by spec. Like findParentTag, but it also matches classNames and static attributes/styles, not just the tag name.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
spec | MarkSpec | Required | — | Declarative mark description: tag plus optional className, attributes, and style. |
from | Node | — | selection start container | Node to start the upward search from. Defaults to the current selection's start container. |
const wrapper = editor.marks.find({ tag: 'mark' });
if (wrapper) {
editor.selection.expandToTag(wrapper);
}marks.read(spec, range?)
MarkSnapshot | nullRead the current values of the spec's declared properties from the wrapper at the range start. Returns null when the range is not inside a matching wrapper. The snapshot carries the matched element plus its declared style properties and attributes. Unset and transparent-valued style properties are omitted.
When to use
Drive your tool's UI from the document: preselect the current colour in a picker before the user changes it.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
spec | MarkSpec | Required | — | Declarative mark description: tag plus optional className, attributes, and style. |
range | Range | — | current selection | Range to read from. Defaults to the live selection's first range. |
const colorMark = {
tag: 'mark',
style: { color: (state) => state.color },
};
// Preselect the current colour in a picker UI
const snapshot = editor.marks.read(colorMark);
const current = snapshot?.style['color']; // e.g. 'rgb(37, 99, 235)'marks.apply(spec, state?, range?)
HTMLElement[]Wrap the range in the mark, or update matching wrappers in place.
- Splits partially-covered same-family wrappers at the range boundaries.
- Extends the range over trailing whitespace that browsers exclude from double-click selections.
- Leaves the new contents selected.
- Returns the created or updated wrapper elements.
A collapsed range (a bare caret with nothing selected) is a no-op: apply returns an empty array without touching the DOM or the selection.
When to use
Idempotent per family: re-applying with new state updates the SAME wrapper in place. That is what makes a colour picker one mark instead of nested cancelling wrappers.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
spec | MarkSpec | Required | — | Declarative mark description: tag plus optional className, attributes, and style. |
state | State | — | — | Value passed to the spec's function-form attribute/style properties. |
range | Range | — | current selection | Range to format. Defaults to the live selection's first range. |
editor.marks.apply(colorMark, { color: '#d97706' });
// Same identity → the SAME wrapper is updated in place, not nested
editor.marks.apply(colorMark, { color: '#2563eb' });marks.remove(spec, range?)
HTMLElement[]Removes the spec's declared properties and classes from wrappers in the range, and unwraps wrappers left bare.
- For a non-collapsed range, partially-covered wrappers are split so text outside the range keeps its formatting.
- A collapsed caret instead targets its enclosing wrapper whole: no split happens, and the spec's properties are stripped from the entire wrapper.
Either way, the selection is restored. Returns the wrappers that survived because they still carry other properties.
When to use
Only the spec's own properties are removed. A wrapper shared with another mark of the family survives with the rest intact.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
spec | MarkSpec | Required | — | Declarative mark description: tag plus optional className, attributes, and style. |
range | Range | — | current selection | Range to deformat. Defaults to the live selection's first range. |
editor.marks.remove(colorMark);
// A <mark> that also carried a background-colour spec survives
// with only the colour strippedmarks.toggle(spec, state?, range?)
booleanCalls remove when the range already carries the mark, and apply otherwise. Returns the resulting state: true when the mark is now applied.
At a collapsed caret with no existing mark it returns true without applying anything, because apply is a no-op on a collapsed range. Call toggle on a non-collapsed range, or treat the return value as the intended state rather than a confirmation.
When to use
The one-liner behind simple toggle tools. Pair it with has() for the active state.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
spec | MarkSpec | Required | — | Declarative mark description: tag plus optional className, attributes, and style. |
state | State | — | — | Value passed to the spec's function-form attribute/style properties. |
range | Range | — | current selection | Range to toggle. Defaults to the live selection's first range. |
const highlight = { tag: 'span', className: 'my-highlight' };
const nowApplied = editor.marks.toggle(highlight);// One spec = one mark. Static values (tag, className, attribute/style
// strings) form the mark's identity; function-form values do not.
const textColor = {
tag: 'mark',
style: { color: (state) => state.color },
};
const bgColor = {
tag: 'mark',
style: { 'background-color': (state) => state.color },
};
// Range-aware: splits partially-covered wrappers at the boundaries,
// updates fully-covering wrappers in place, restores the selection
editor.marks.apply(textColor, { color: '#2563eb' });
// Same family (same tag, no conflicting statics) → composes on ONE element
editor.marks.apply(bgColor, { color: '#fef3c7' });
// → <mark style="color: #2563eb; background-color: #fef3c7">…</mark>
// Derive the sanitizer rule the mark produces for a vanilla inline tool
import { markSanitizerConfig } from '@bloklabs/core';
class TextColorTool {
static get sanitize() {
return markSanitizerConfig(textColor);
}
}