question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Triple-click selection and Selection API issue

See original GitHub issue

Description Hello! We are currently facing a bug with triple-click selection. When we triple-click on a word, the expectation is that the whole text block gets selected, a paragraph for example. The problem here is that for 2 of the 3 browsers I tested (it happens on Chrome and Edge, not on Firefox), Slate will also select the following (or part of the following) sibling node.

You can see recordings for both behaviours, the first for Chrome (bug) and the second on Firefox (no bug).

Recording

https://user-images.githubusercontent.com/29894873/121357512-29193e00-c932-11eb-91d9-54da429cea47.mov

https://user-images.githubusercontent.com/29894873/121357551-2e768880-c932-11eb-8bc7-62b05be9e5a2.mov

Steps To reproduce the behavior:

  1. Go to https://www.slatejs.org/examples/images
  2. Triple-click on the first paragraph
  3. On Chrome the image gets selected as well, not on Firefox

Expectation The expectation would be to have the selection only on the text block that was actually targeted, and not the following one.

Environment

  • Slate Version: latest
  • Operating System: macOS 10.15.7
  • Browser: Chrome, Edge

Context From my understanding, there is two things that feel are part of the issue.

  1. The first might be because of the way offsets are considered within a selection. At this line: https://github.com/ianstormtaylor/slate/blob/e042ebd4a2539aeabf0996e8f1a1dad1be5860c0/packages/slate-react/src/utils/dom.ts#L110 offset is compared to childNodes.length But according to the Selection API the offset represents the number of characters selected, not the number of child nodes, so this is not comparing the same things at all.

  2. The second thing is, if the value of focusOffset is 0, it means there is nothing selected in the focusNode and therefore, this node should be removed from the selection range. Currently, maybe because of the first point, Slate keeps digging into this focusNode to find a selectable child and include it in the range. On top of it, if focusNode is a void element, it can create more problems, as Slate is not supposed to handle the content of a void element.

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Reactions:5
  • Comments:6 (3 by maintainers)

github_iconTop GitHub Comments

2reactions
jakedcommented, Sep 30, 2021

I’ve been investigating / trying to fix this (see comments on #4492) and wanted to record some findings:

The basic issue as I understand it is that the DOM selection API supports two kinds of selection endpoint:

  • text node + character offset (I’ll call it a “text endpoint”)
  • element node + child offset (I’ll call it an “element endpoint”), used to signify that entire elements are in the selection, not just the text inside them

but Slate selections support only text endpoints.

When a DOM selection has an element endpoint, Slate looks for a corresponding text endpoint (ReactEditor.toSlatePoint). In most cases, this text endpoint is after the original element endpoint; so when the focus of a forward selection is an element endpoint, the Slate selection is “hanging”: its focus is at the beginning of the following block.

Browsers vary on exactly what actions produce element endpoints, but they are generally actions that sensibly mean “select the whole element”:

  • triple-click on a paragraph
  • place the cursor at the beginning of a paragraph, shift-arrow-down past the end of the paragraph
  • place the cursor at the beginning of a paragraph, shift-arrow-right to the end of the paragraph (selects the text within the paragraph), then shift-arrow-right again (selects the whole paragraph)
  • drag the cursor from the beginning of a paragraph to the end (selects the text within the paragraph), then drag a little further / down (selects the whole paragraph)

All of these generate element endpoints (in most cases) in the browsers I’ve tested (Chrome, Firefox, Safari, all on a Mac), and in most cases the element endpoints turn into a hanging selection in Slate.

It’s good to have hanging selections! There’s no other way in Slate to represent the selection of a whole block, and there are lots of actions that sensibly work on a whole block: e.g. if you triple-click a paragraph or shift-arrow-down a paragraph and hit delete, it’s sensible for the whole paragraph to be deleted, not just the text within it. (I haven’t tested a lot of rich text editors on this but it seems pretty standard.)

But hanging selections have some bugs:

First, browsers differ in exactly how they generate selection endpoints for the actions above:

  • Chrome always generates an element endpoint for the selection focus, which points to an element preceding the next editable text location, so Slate generates a hanging selection.
  • Safari behaves like Chrome, except when there is an image before the next editable location; then it generates a text endpoint in the original paragraph (so Slate generates a non-hanging selection).
  • Firefox on triple-click generates an element endpoint for the selection focus, which points to the selected paragraph with offset past the last text child. But Slate turns this into a non-hanging selection (because DOM.normalizeDOMPoint searches backwards for a text node when the offset is past the last child).
  • Firefox on the other actions generates a text endpoint at the next editable text location (i.e. the DOM selection is already hanging, and Slate generates a hanging selection), except when there is an image before the next editable location; then it generates an element endpoint preceding the next editable text location, so Slate generates a hanging selection.

(I feel like these differences are not really a problem—applications should do something sensible with both hanging and non-hanging selections; so inconsistencies in when hanging / non-hanging selections are generated don’t really matter. Just my opinion!)

Second, applications don’t always handle hanging selections correctly:

  • In the rich text example, as @bytrangle points out above, the quotations toolbar button is incorrectly highlighted for hanging selections, because the selection is not unhanged with Range.unhang before checking the nodes. I think this is a bug in the example—it should properly handle both hanging and non-hanging selections. (Note that you can’t currently reproduce this in Chrome due to #4455 / #4512; it’s reproducible in Safari with a triple-click or shift-arrow-down, or in Firefox with shift-arrow-down.)
  • In the images example, as @julienmonnard points out above, the image is highlighted for hanging selections (and is deleted if you press delete). I don’t totally understand what’s happening here, but it seems like a hanging selection that ends inside a void node is not handled correctly in Slate. This is separate from the varying browser behavior with following images (which affects whether the selection is hanging or not).

@bytrangle made an attempt at fixing these bugs with #4455 / #4512. I’ve been looking into alternative fixes because @bytrangle’s fixes have caused other bugs (#4492), because they address Chrome only (the issue is more widespread), and because they fix the bugs by getting rid of hanging selections (which I think is a mistake; hanging ranges are useful).

I’ve been working on a branch that addresses these bugs by always looking for the text endpoint inside the DOM selection, never outside. This works without browser-specific fixups, but it also gets rid of hanging selections (as above I think this is a mistake). I’d rather address the bugs by fixing the examples where needed, and by understanding / fixing the behavior of hanging selections inside void nodes. What do you think? @ianstormtaylor would love your input here.

0reactions
julienmonnardcommented, Aug 6, 2021

So first of all, to not mix everything below, there is different things at play here:

  • 2 different types of nodes: DOM Node and Slate Node, which are not the same.
  • 2 different types of selection object: Selection from Selection API and the Slate Selection, different things as well.

I will try to keep these names.

If you do a single click on you paragraph, for example between the letters o and l of the word bold, the Selection object (from Selection API) will contain this (not only, but I keep the useful part here)

{
    anchorNode: text, // "text" is a `DOM Node` representing the word "bold"
    anchorOffset: 2,
    focusNode: text, // "text" is a `DOM Node` representing the word "bold"
    focusOffset: 2,
}

Here, the two text are DOM Nodes representing the word bold and the 2 is after how many characters of that word you clicked. Based on that, Slate will return the first valid Slate Node in which this text is. In our case, the full paragraph. It will then assign it to the Slate Selection.

Now if you do the “click-and-drag”, your Selection object (from API) will be:

// If you dragged from beginning to end
{
    anchorNode: text, // "text" is a `DOM Node` representing the text until just before "bold"
    anchorOffset: 0,
    focusNode: text, // "text" is a `DOM Node` representing the text from the comma until the end
    focusOffset: 82,
}
// If you dragged from end to beginning
{
    anchorNode: text, // "text" is a `DOM Node` representing the text from the comma until the end
    anchorOffset: 82,
    focusNode: text, // "text" is a `DOM Node` representing the text until just before "bold"
    focusOffset: 0,
}

For Slate, it’s the same as before, it will get the first valid Slate Node that contains your selection, in this case still the same paragraph, give it to the Slate Selection.

And for the triple click:

// in Chrome
{
    anchorNode: text, // "text" is a `DOM Node` representing the text until just before "bold"
    anchorOffset: 0,
    focusNode: text, // Now "text" is a `DOM Node` representing the text of the quote
    focusOffset: 0,
}

// in Firefox
{
    anchorNode: text, // "text" is a `DOM Node` representing the text until just before "bold"
    anchorOffset: 0,
    focusNode: text, // "text" is a `DOM Node` representing the text from the comma until the end
    focusOffset: 82,
}

Since here the Selection API returns the text of the quote as a focusNode (DOM Node), Slate detects that this is not part of the paragraph you “triple-selected”, and will therefore add the Quote element to your Slate Selection. And this last part will trigger all the issues we noticed, like the trigger of isBlockActive and the image issue.

The problem here is that since the focusOffset is at 0, it means that no characters from the focusNode is actually selected, so Slate should not be giving that DOM Node to the Slate Selection. To make the connection with my initial issue, this is not a problem with Firefox because unlike Chrome, it already removes this “unselected node” before returning the selection, so Slate doesn’t even know about it

Read more comments on GitHub >

github_iconTop Results From Across the Web

Selection after a triple-click (to select the whole element) has ...
It fails to apply surroundContents because the selection after a triple-click extends to the next node, instead of being at the end of...
Read more >
32807 - Triple-clicking should select paragraph, not line
In this bug, however, selection starts at the second character in a line instead of the first (or instead of the beginning of...
Read more >
Triple click text in Chrome contains the following node in range
It seems that the triple click action in Chrome is selecting whole line, including end-of-line characters. That is why the endContainer is ...
Read more >
CodeMirror: Release History - Neuromics
Make sure long-clicking a selection sets a cursor and doesn't show the editor losing focus. Fix issue where pointer events were incorrectly disabled...
Read more >
Selection API - W3C
Perhaps most significantly, all sorts of problems might arise when DOM mutations transpire, like if a boundary point's node is removed from its ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found