selection improvements

This commit is contained in:
Ilya Kantor 2021-03-25 19:28:25 +03:00
parent d4b3c135cc
commit 33f8b4c4de
3 changed files with 116 additions and 50 deletions

View file

@ -26,18 +26,45 @@ let range = new Range();
Then we can set the selection boundaries using `range.setStart(node, offset)` and `range.setEnd(node, offset)`.
The first argument `node` can be either a text node or an element node. The meaning of the second argument depends on that:
As you might guess, further we'll use the `Range` objects for selection, but first let's create few such objects.
- If `node` is a text node, then `offset` must be the position in the text.
- If `node` is an element node, then `offset` must be the child number.
### Selecting the text partially
For example, let's create a range in this fragment:
The interesting thing is that the first argument `node` in both methods can be either a text node or an element node, and the meaning of the second argument depends on that.
**If `node` is a text node, then `offset` must be the position in its text.**
For example, given the element `<p>Hello</p>`, we can create the range containing the letters "ll" as follows:
```html run
<p id="p">Hello</p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.firstChild, 4);
// toString of a range returns its content as text
console.log(range); // ll
</script>
```
Here we take the first child of `<p>` (that's the text node) and specify the text positions inside it:
![](range-hello-1.svg)
### Selecting element nodes
**Alternatively, if `node` is an element node, then `offset` must be the child number.**
That's handy for making ranges that contain nodes as a whole, not stop somewhere inside their text.
For example, we have a more complex document fragment:
```html autorun
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
```
Here's its DOM structure:
Here's its DOM structure with both element and text nodes:
<div class="select-p-domtree"></div>
@ -77,14 +104,18 @@ drawHtmlTree(selectPDomtree, 'div.select-p-domtree', 690, 320);
Let's make a range for `"Example: <i>italic</i>"`.
As we can see, this phrase consists of exactly the first and the second children of `<p>`:
As we can see, this phrase consists of exactly two children of `<p>`, with indexes `0` and `1`:
![](range-example-p-0-1.svg)
- The starting point has `<p>` as the parent `node`, and `0` as the offset.
So we can set it as `range.setStart(p, 0)`.
- The ending point also has `<p>` as the parent `node`, but `2` as the offset (it specifies the range up to, but not including `offset`).
Here's the demo, if you run it, you can see that the text gets selected:
So we can set it as `range.setEnd(p, 2)`.
Here's the demo. If you run it, you can see that the text gets selected:
```html run
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
@ -100,12 +131,12 @@ Here's the demo, if you run it, you can see that the text gets selected:
// toString of a range returns its content as text, without tags
console.log(range); // Example: italic
// let's apply this range for document selection (explained later)
// apply this range for document selection (explained later below)
document.getSelection().addRange(range);
</script>
```
Here's a more flexible test stand where you try more variants:
Here's a more flexible test stand where you can set range start/end numbers and explore other variants:
```html run autorun
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
@ -121,26 +152,28 @@ From <input id="start" type="number" value=1> To <input id="end" type="numbe
range.setEnd(p, end.value);
*/!*
// apply the selection, explained later
// apply the selection, explained later below
document.getSelection().removeAllRanges();
document.getSelection().addRange(range);
};
</script>
```
E.g. selecting in the same `<p>` from offset `1` to `4` gives range `<i>italic</i> and <b>bold</b>`:
E.g. selecting in the same `<p>` from offset `1` to `4` gives us the range `<i>italic</i> and <b>bold</b>`:
![](range-example-p-1-3.svg)
We don't have to use the same node in `setStart` and `setEnd`. A range may span across many unrelated nodes. It's only important that the end is after the start.
```smart header="Starting and ending nodes can be different"
We don't have to use the same node in `setStart` and `setEnd`. A range may span across many unrelated nodes. It's only important that the end is after the start in the document.
```
### Selecting parts of text nodes
### Selecting a bigger fragment
Let's select the text partially, like this:
Let's make a bigger selection in our example, like this:
![](range-example-p-2-b-3.svg)
That's also possible, we just need to set the start and the end as a relative offset in text nodes.
We already know how to do that. We just need to set the start and the end as a relative offset in text nodes.
We need to create a range, that:
- starts from position 2 in `<p>` first child (taking all but two first letters of "Ex<b>ample:</b> ")
@ -162,7 +195,13 @@ We need to create a range, that:
</script>
```
The range object has following properties:
As you can see, it's fairly easy to make a range of whatever we want.
If we'd like to take nodes as a whole, we can pass elements in `setStart/setEnd`. Otherwise, we can work on the text level.
## Range properties
The range object that we created in the example above has following properties:
![](range-example-p-2-b-3-range.svg)
@ -175,10 +214,13 @@ The range object has following properties:
- `commonAncestorContainer` -- the nearest common ancestor of all nodes within the range,
- in the example above: `<p>`
## Range methods
## Range selection methods
There are many convenience methods to manipulate ranges.
We've already seen `setStart` and `setEnd`, here are other similar methods.
Set range start:
- `setStart(node, offset)` set start at: position `offset` in `node`
@ -191,15 +233,19 @@ Set range end (similar methods):
- `setEndBefore(node)` set end at: right before `node`
- `setEndAfter(node)` set end at: right after `node`
**As it was demonstrated, `node` can be both a text or element node: for text nodes `offset` skips that many of characters, while for element nodes that many child nodes.**
Technically, `setStart/setEnd` can do anything, but more methods provide more convenience.
Others:
In all these methods, `node` can be both a text or element node: for text nodes `offset` skips that many of characters, while for element nodes that many child nodes.
Even more methods to create ranges:
- `selectNode(node)` set range to select the whole `node`
- `selectNodeContents(node)` set range to select the whole `node` contents
- `collapse(toStart)` if `toStart=true` set end=start, otherwise set start=end, thus collapsing the range
- `cloneRange()` creates a new range with the same start/end
To manipulate the content within the range:
## Range editing methods
Once the range is created, we can manipulate its content using these methods:
- `deleteContents()` -- remove range content from the document
- `extractContents()` -- remove range content from the document and return as [DocumentFragment](info:modifying-document#document-fragment)
@ -271,7 +317,9 @@ There also exist methods to compare ranges, but these are rarely used. When you
## Selection
`Range` is a generic object for managing selection ranges. We may create `Range` objects, pass them around -- they do not visually select anything on their own.
`Range` is a generic object for managing selection ranges. Although, creating a `Range` doesn't mean that we see a selection on screen.
We may create `Range` objects, pass them around -- they do not visually select anything on their own.
The document selection is represented by `Selection` object, that can be obtained as `window.getSelection()` or `document.getSelection()`. A selection may include zero or more ranges. At least, the [Selection API specification](https://www.w3.org/TR/selection-api/) says so. In practice though, only Firefox allows to select multiple ranges in the document by using `key:Ctrl+click` (`key:Cmd+click` for Mac).
@ -281,9 +329,19 @@ Here's a screenshot of a selection with 3 ranges, made in Firefox:
Other browsers support at maximum 1 range. As we'll see, some of `Selection` methods imply that there may be many ranges, but again, in all browsers except Firefox, there's at maximum 1.
Here's a small demo that shows the current selection (select something and click) as text:
<button onclick="alert(document.getSelection())">alert(document.getSelection())</button>
## Selection properties
Similar to a range, a selection has a start, called "anchor", and the end, called "focus".
As said, a selection may in theory contain multiple ranges. We can get these range objects using the method:
- `getRangeAt(i)` -- get i-th range, starting from `0`. In all browsers except Firefox, only `0` is used.
Also, there exist properties that often provide better convenience.
Similar to a range, a selection object has a start, called "anchor", and the end, called "focus".
The main selection properties are:
@ -294,36 +352,39 @@ The main selection properties are:
- `isCollapsed` -- `true` if selection selects nothing (empty range), or doesn't exist.
- `rangeCount` -- count of ranges in the selection, maximum `1` in all browsers except Firefox.
````smart header="Usually, the selection end `focusNode` is after its start `anchorNode`, but it's not always the case"
There are many ways to select the content, depending on the user agent: mouse, hotkeys, taps on a mobile etc.
```smart header="Selection end/start vs Range"
Some of them, such as a mouse, allow the same selection can be created in two directions: "left-to-right" and "right-to-left".
There's an important differences of a selection anchor/focus compared with a `Range` start/end.
If the start (anchor) of the selection goes in the document before the end (focus), this selection is said to have "forward" direction.
As we know, `Range` objects always have their start before the end.
For selections, that's not always the case.
Selecting something with a mouse can be done in both directions: either "left-to-right" or "right-to-left".
In other words, when the mouse button is pressed, and then it moves forward in the document, then its end (focus) will be after its start (anchor).
E.g. if the user starts selecting with mouse and goes from "Example" to "italic":
![](selection-direction-forward.svg)
Otherwise, if they go from the end of "italic" to "Example", the selection is directed "backward", its focus will be before the anchor:
...But the same selection could be done backwards: starting from "italic" to "Example" (backward direction), then its end (focus) will be before the start (anchor):
![](selection-direction-backward.svg)
That's different from `Range` objects that are always directed forward: the range start can't be after its end.
````
```
## Selection events
There are events on to keep track of selection:
- `elem.onselectstart` -- when a selection starts on `elem`, e.g. the user starts moving mouse with pressed button.
- Preventing the default action makes the selection not start.
- `document.onselectionchange` -- whenever a selection changes.
- Please note: this handler can be set only on `document`.
- `elem.onselectstart` -- when a selection *starts* on speficially elemen `elem` (or inside it). For instance, when the user presses the mouse button on it and starts to move the pointer.
- Preventing the default action cancels the selection start. So starting a selection from this element becomes impossible, but the element is still selectable. The visitor just needs to start the selection from elsewhere.
- `document.onselectionchange` -- whenever a selection changes or starts.
- Please note: this handler can be set only on `document`, it tracks all selections in it.
### Selection tracking demo
Here's a small demo that shows selection boundaries dynamically as it changes:
Here's a small demo. It tracks the current selection on the `document` and shows its boundaries:
```html run height=80
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
@ -331,21 +392,25 @@ Here's a small demo that shows selection boundaries dynamically as it changes:
From <input id="from" disabled> To <input id="to" disabled>
<script>
document.onselectionchange = function() {
let {anchorNode, anchorOffset, focusNode, focusOffset} = document.getSelection();
let selection = document.getSelection();
from.value = `${anchorNode && anchorNode.data}:${anchorOffset}`;
to.value = `${focusNode && focusNode.data}:${focusOffset}`;
let {anchorNode, anchorOffset, focusNode, focusOffset} = selection;
// anchorNode and focusNode are text nodes usually
from.value = `${anchorNode?.data}, offset ${anchorOffset}`;
to.value = `${focusNode?.data}, offset ${focusOffset}`;
};
</script>
```
### Selection getting demo
### Selection copying demo
To get the whole selection:
- As text: just call `document.getSelection().toString()`.
- As DOM nodes: get the underlying ranges and call their `cloneContents()` method (only first range if we don't support Firefox multiselection).
There are two approaches to copying the selected content:
And here's the demo of getting the selection both as text and as DOM nodes:
1. We can use `document.getSelection().toString()` to get it as text.
2. Otherwise, to copy the full DOM, e.g. if we need to keep formatting, we can get the underlying ranges with `getRangesAt(...)`. A `Range` object, in turn, has `cloneContents()` method that clones its content and returns as `DocumentFragment` object, that we can insert elsewhere.
Here's the demo of copying the selected content both as text and as DOM nodes:
```html run height=100
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
@ -373,15 +438,15 @@ As text: <span id="astext"></span>
## Selection methods
Selection methods to add/remove ranges:
We can work with the selection by addding/removing ranges:
- `getRangeAt(i)` -- get i-th range, starting from `0`. In all browsers except firefox, only `0` is used.
- `getRangeAt(i)` -- get i-th range, starting from `0`. In all browsers except Firefox, only `0` is used.
- `addRange(range)` -- add `range` to selection. All browsers except Firefox ignore the call, if the selection already has an associated range.
- `removeRange(range)` -- remove `range` from the selection.
- `removeAllRanges()` -- remove all ranges.
- `empty()` -- alias to `removeAllRanges`.
Also, there are convenience methods to manipulate the selection range directly, without `Range`:
There are also convenience methods to manipulate the selection range directly, without intermediate `Range` calls:
- `collapse(node, offset)` -- replace selected range with a new one that starts and ends at the given `node`, at position `offset`.
- `setPosition(node, offset)` -- alias to `collapse`.
@ -393,7 +458,7 @@ Also, there are convenience methods to manipulate the selection range directly,
- `deleteFromDocument()` -- remove selected content from the document.
- `containsNode(node, allowPartialContainment = false)` -- checks whether the selection contains `node` (partially if the second argument is `true`)
So, for many tasks we can call `Selection` methods, no need to access the underlying `Range` object.
For most tasks these methods are just fine, there's no need to access the underlying `Range` object.
For example, selecting the whole contents of the paragraph `<p>`:
@ -420,10 +485,10 @@ The same thing using ranges:
</script>
```
```smart header="To select, remove the existing selection first"
If the selection already exists, empty it first with `removeAllRanges()`. And then add ranges. Otherwise, all browsers except Firefox ignore new ranges.
```smart header="To select something, remove the existing selection first"
If a document selection already exists, empty it first with `removeAllRanges()`. And then add ranges. Otherwise, all browsers except Firefox ignore new ranges.
The exception is some selection methods, that replace the existing selection, like `setBaseAndExtent`.
The exception is some selection methods, that replace the existing selection, such as `setBaseAndExtent`.
```
## Selection in form controls

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="147" height="80" viewBox="0 0 147 80"><defs><style>@import url(https://fonts.googleapis.com/css?family=Open+Sans:bold,italic,bolditalic%7CPT+Mono);@font-face{font-family:&apos;PT Mono&apos;;font-weight:700;font-style:normal;src:local(&apos;PT MonoBold&apos;),url(/font/PTMonoBold.woff2) format(&apos;woff2&apos;),url(/font/PTMonoBold.woff) format(&apos;woff&apos;),url(/font/PTMonoBold.ttf) format(&apos;truetype&apos;)}</style></defs><defs><path id="path-1" d="M63 17h25v21H63z"/><mask id="mask-2" width="25" height="21" x="0" y="0" fill="#fff" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox"><use xlink:href="#path-1"/></mask></defs><g id="selection-range" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><g id="range-hello-1.svg"><text id="&lt;p&gt;Hello&lt;/p&gt;" fill="#8A704D" font-family="PTMono-Regular, PT Mono" font-size="18" font-weight="normal" letter-spacing="1"><tspan x="5" y="33">&lt;p&gt;Hello&lt;/p&gt;</tspan></text><path id="Path-Copy" stroke="#EE6B47" stroke-width="2" d="M41 38v11h58.887V37"/><text id="p.firstChild" fill="#000" font-family="PTMono-Regular, PT Mono" font-size="14" font-weight="normal"><tspan x="25" y="69">p.firstChild</tspan></text><use id="Rectangle" fill="#00F" fill-opacity=".1" stroke="#00F" stroke-dasharray="5,2" stroke-width="2" mask="url(#mask-2)" xlink:href="#path-1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.