Selecting the unselectable, how we rebuilt block selection in Framer’s rich-text editor

Published

Published

0 min read

Framer CMS has to handle content most rich text editors never deal with: React components, embedded media, complex multimedia. Making selection work with those while keeping the UX great is surprisingly hard in ProseMirror. Here’s why its selection model breaks down with our content, and the plugin we built to solve it.

Understanding the document structure

Framer’s rich-text editor is built on ProseMirror, a powerful editing library. However, its default selection model does not handle some of the content Framer renders. To understand why, we first have to distinguish between the two kinds of content a CMS rich-text field can hold: text, and blocks. Blocks cover everything that is not text:

  • Images

  • Videos

  • YouTube content

  • Code blocks

  • Tables

  • Components

In Framer’s ProseMirror schema, these blocks are leaf nodes: they cannot contain other content and must sit at the document’s root level.

For example, consider the following sentence:

The team published a new homepage.

ProseMirror represents it like this:

<p>The team published a new homepage.</p>
<p>The team published a new homepage.</p>
<p>The team published a new homepage.</p>

Inline formatting can be contained within a paragraph. For example, bolding one word changes this:

The team published a new homepage.

Into this:

<p>The team <strong>published</strong> a new homepage.</p>
<p>The team <strong>published</strong> a new homepage.</p>
<p>The team <strong>published</strong> a new homepage.</p>

Blocks are different. In Framer’s data model, a schema decision rather than a ProseMirror requirement, they must live at the document’s root level and cannot be nested inside another element. An image is one example:

The team published a new homepage.

ProseMirror represents that structure like this:

<p>The team published a new homepage.</p><img src="url-of-homepage-preview" />
<p>The team published a new homepage.</p><img src="url-of-homepage-preview" />
<p>The team published a new homepage.</p><img src="url-of-homepage-preview" />

Can you spot what happened? The image isn’t inside the <p> element but placed at the document’s root level. This distinction, along with the way ProseMirror assigns positions, is the source of the selection problems we encountered.

How does ProseMirror’s selection system work?

When you select content in the CMS, ProseMirror tracks the selection using document positions. A position is a number that counts the boundaries between content from left to right.

ProseMirror calculates these positions using three rules:

  • Entering or leaving a node that contains content, such as a paragraph, counts as 1 for its opening or closing token.

  • Each text character counts as 1.

  • A leaf or atom node, such as an image, counts as 1 in total because it has no positions inside it.

Consider this text:

Hello

Its positions are structured like this:

0
<p>
1
H
2
e
3
l
4
l
5
o
6
</p>
7

Every boundary between letters counts as one position, following the first two rules.

Now place an image between two pieces of text:

Hello
Embedded image
World

ProseMirror structures it like this:

0
<p>
1
H
2
e
3
l
4
l
5
o
6
7
</p>
8
Embedded image<img block>
9

The first two rules still apply, but the image introduces the third:

A leaf or atom node, such as an image, counts as 1 as a whole because there are no positions inside it.

One more thing. Notice the “red” tokens. They represent positions at the document level, outside nodes such as paragraphs. Vanilla ProseMirror cannot use these positions as endpoints for a text selection.

ProseMirror normally represents selected content with TextSelection which is the one that have this red token limitation. The combination of TextSelection and document-level positions created the limitations users experienced in Framer’s CMS editor.

Why TextSelection is not enough

Now that we know how ProseMirror assigns positions, let’s see what happens when a user selects text.

Suppose we have this word and selection:

Hello

ProseMirror converts this pointer gesture into a TextSelection instance containing four relevant values:

From

The lowest position covered by the selection

To

The highest position covered by the selection

Anchor

The position where the drag began

Head

The position where the drag ended

For a left-to-right selection, the data looks like this:

0
<p>
1
H
Afrom
2
e
3
l
4
l
Hto
5
o
6
</p>
7
A = anchorH = headtokens inside [from, to]

Here, from and to are 2 and 5 because those are the ordered boundaries of the visible selection. Anchor and head are also 2 and 5 because the drag began at 2 and ended at 5.

What is the difference between anchor and head, and from and to?

anchor and head preserve how the user made the selection: anchor stays fixed where the gesture began, while head follows the cursor and therefore records the selection’s direction. From and to describe the same selection as an ordered document range. Commands such as delete, format, and render use that range without needing to know its direction.

Check this animation, which simulates somebody dragging from one side of the sentence to the other, and see how from and to change while anchor and head remain the same.

0
<p>
1
H
2
e
3
l
4
l
5
o
6
</p>
7
A = anchorH = headtokens inside [from, to]

So, is that the whole story?

Unfortunately, no. TextSelection has an important limitation: neither its anchor nor its head endpoint can be a document-level position. In simplified terms, a text selection expects the caret to live at its head. Here is what that means using this example:

0
Embedded image<img block>
1
<p>
2
3
H
4
e
5
l
6
l
7
o
8
</p>
9

If the head is placed at position 9, or the anchor begins at position 0, ProseMirror cannot place a caret there. Not because a text node is missing (a caret can sit in an empty paragraph), but because the parent at that position doesn’t accept inline content. It’s like the caret couldn’t “glue” to the position because it’s a document-level one. As a result, TextSelection cannot represent the selection.

This was the key problem. Previously, Framer could reliably represent only two selection scenarios:

  1. Text, including a range that spans text, a block, and more text

  2. A single block

Selections outside those scenarios were impossible or unreliable. For example:

  • Blocks could not be selected precisely with the pointer: a drag would not stop on the targeted block. With two adjacent blocks, such as images, users could select either both or neither.

  • Selections combining text and a block lost character-level precision: as soon as a block entered the range, a selection such as half a sentence plus one image could no longer be represented.

  • A block at the end of a rich-text field could not be selected because no valid text position existed beyond it.

  • Shift + ↑ or Shift + ↓ could not adjust a selection across blocks predictably: the command either stopped at the block boundary or selected everything through the next paragraph at once.

  • Blocks inside a selection were not highlighted reliably: the result depended on the direction of the drag.

We also relied on the browser’s native highlighting for blocks, which produced an inconsistent experience that did not match the quality of Framer’s interface.

Building a better selection model

We solved the problem by implementing the selection behavior our product needed in a plugin called blockSelectionPlugin.

The plugin handles two extra scenarios, each represented by a custom Selection class:

  • Text and blocks are selected together: MixedSelection

  • Only blocks are selected: BlockSelection

The guiding rule

One rule governs the entire plugin: always use the most precise selection the model can represent. If both endpoints are inside text, the editor uses a native TextSelection for character-level precision. If only one endpoint is inside text, it uses MixedSelection. If neither endpoint is inside text, it uses BlockSelection. The plugin decides which class best represents the gesture. Each class then handles its own behavior.

ProseMirror does ship a native NodeSelection for selecting a single block, but it only covers one node at a time. It cannot represent a range spanning several blocks, or a mix of text and blocks, which is exactly the gap MixedSelection and BlockSelection fill.

Scenario 1. MixedSelection: text and blocks

MixedSelection represents something that was previously impossible: one endpoint with character-level precision and the other attached to a block.

Recall the limitation of TextSelection: both anchor and head must be positions where a caret can exist. MixedSelection replaces that requirement with its own rule:

The anchor must be inside text, at a green token, even in the middle of a word. The head must be at a document-level position: a red token at a block’s edge.

Consider this example document:

0
<p>
1
H
2
e
3
l
Afrom
4
l
5
o
6
7
</p>
8
Embedded image<img block>
Hto
9
<p>
10
11
w
12
o
13
r
14
l
15
d
16
</p>
17
A = anchorH = headtokens inside [from, to]

The resulting MixedSelection contains exactly “lo” and the image: half a sentence and one block, now represented as a first-class selection.


Now check this last one. The selection also works naturally with the keyboard. Imagine we press Shift +

0
<p>
1
H
2
e
3
l
Afrom
4
l
5
o
6
7
</p>
8
Embedded image<img block>
9
<p>
10
11
w
12
o
13
r
14
l
15
d
Hto
16
</p>
17
A = anchorH = headtokens inside [from, to]

As soon as the head enters “World,” the plugin returns to a native TextSelection. Both endpoints are in text again, so the most precise class takes over. The anchor remains fixed throughout the cycle.

Scenario 2. BlockSelection: blocks only

When neither endpoint is in text, for example when an image is selected and Shift + adds the next image, there is no character position to use as an anchor. BlockSelection handles this by placing both endpoints at document-level positions, each immediately before a block.

Consider two adjacent images:

0
<p>
1
H
2
e
3
l
4
l
5
o
6
7
</p>
Afrom
8
Embedded image<img block>
Hto
9
Embedded image<img block>
10
<p>
11
12
w
13
o
14
r
15
l
16
d
17
</p>
18
A = anchorH = headtokens inside [from, to]

Selecting both images creates a BlockSelection with head 9 and anchor 8.

But position 9 is before the second image, even though the selection must end after it. How does that work?

The key is that those two numbers are not the highlight. Instead, they identify which blocks are selected.

An image occupies one token and has no internal positions, so the natural way to identify it with a single number is by using the convention ProseMirror itself uses which is using the document-level position immediately before it. Think of that position as the block’s label: 8 identifies the first image, and 9 identifies the second. Anchor 8 and head 9 therefore mean “select from the first image through the second,” much like selecting a spreadsheet range from B2 to C3.

The class derives the highlighted document range from those labels: it starts before the first block and ends after the last. It takes the head’s label, 9, adds the size of the referenced image, 1, and gets 10. The resulting range is [8, 10], which fully contains both images.

Why not store [8, 10] directly? Because an ordered range loses direction. It cannot tell us whether the user anchored on the first image and extended downward or anchored on the second and extended upward. Shift + and Shift + need that direction to know which endpoint should move and whether the next action should grow or shrink the selection.

That derived range is the crucial detail. ProseMirror’s built-in commands, including Backspace, Cmd+C, and typing to replace, operate on the range without caring which Selection class produced it. Because BlockSelection derives the range correctly, deleting removes exactly the selected images, copying places both complete blocks on the clipboard, and undo restores them. All of that works without modifying those native commands.

A consistent visual selection

Browsers do not know how to draw either custom selection because there is no native selection between blocks. Both classes therefore hide their native appearance, and the plugin draws the selection itself: an outline and tinted overlay on every selected block, plus a text highlight for the text portion of a MixedSelection. This also eliminates a Chrome quirk that made block highlighting depend on drag direction. The result is deterministic and matches Framer’s interface rather than the browser’s default styling.

Wrapping up

Every scenario that was broken at the start of this post now works: precise pointer selection on blocks, text plus a block with character precision, blocks at the end of a field, predictable Shift + / across blocks.

The takeaway: ProseMirror's selection model is a default, not a constraint. The hard part wasn't writing the classes, it was finding the rule that picks between them: always use the most precise selection the model can represent. Everything else followed from it.

Problems like this are everyday work at Framer. If that sounds fun, we're hiring.

Try it yourself

Try the selection behavior yourself in this interactive position visualizer.

0
<p>
1
U
2
s
3
e
4
5
m
6
e
7
</p>
8

Sign up for Framer for free and get 500 free Agent credits every month

Sign up for Framer for free and get 500 free Agent credits every month

Your next idea starts here

GPT 5.6 Terra

Create personal portfolio

Build startup site

Launch landing page

Start company blog

Framer UI showing Pages, Layers and Assets panels titles
Framer UI showing Pages, Layers and Assets panels titles