selection improvements
This commit is contained in:
parent
d4b3c135cc
commit
33f8b4c4de
3 changed files with 116 additions and 50 deletions
|
@ -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)`.
|
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.
|
### Selecting the text partially
|
||||||
- If `node` is an element node, then `offset` must be the child number.
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
```html autorun
|
||||||
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
|
<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>
|
<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>"`.
|
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`:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- The starting point has `<p>` as the parent `node`, and `0` as the offset.
|
- 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`).
|
- 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
|
```html run
|
||||||
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
|
<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
|
// toString of a range returns its content as text, without tags
|
||||||
console.log(range); // Example: italic
|
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);
|
document.getSelection().addRange(range);
|
||||||
</script>
|
</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
|
```html run autorun
|
||||||
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
|
<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);
|
range.setEnd(p, end.value);
|
||||||
*/!*
|
*/!*
|
||||||
|
|
||||||
// apply the selection, explained later
|
// apply the selection, explained later below
|
||||||
document.getSelection().removeAllRanges();
|
document.getSelection().removeAllRanges();
|
||||||
document.getSelection().addRange(range);
|
document.getSelection().addRange(range);
|
||||||
};
|
};
|
||||||
</script>
|
</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>`:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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:
|
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> ")
|
- 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>
|
</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:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
@ -175,10 +214,13 @@ The range object has following properties:
|
||||||
- `commonAncestorContainer` -- the nearest common ancestor of all nodes within the range,
|
- `commonAncestorContainer` -- the nearest common ancestor of all nodes within the range,
|
||||||
- in the example above: `<p>`
|
- in the example above: `<p>`
|
||||||
|
|
||||||
## Range methods
|
|
||||||
|
## Range selection methods
|
||||||
|
|
||||||
There are many convenience methods to manipulate ranges.
|
There are many convenience methods to manipulate ranges.
|
||||||
|
|
||||||
|
We've already seen `setStart` and `setEnd`, here are other similar methods.
|
||||||
|
|
||||||
Set range start:
|
Set range start:
|
||||||
|
|
||||||
- `setStart(node, offset)` set start at: position `offset` in `node`
|
- `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`
|
- `setEndBefore(node)` set end at: right before `node`
|
||||||
- `setEndAfter(node)` set end at: right after `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`
|
- `selectNode(node)` set range to select the whole `node`
|
||||||
- `selectNodeContents(node)` set range to select the whole `node` contents
|
- `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
|
- `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
|
- `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
|
- `deleteContents()` -- remove range content from the document
|
||||||
- `extractContents()` -- remove range content from the document and return as [DocumentFragment](info:modifying-document#document-fragment)
|
- `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
|
## 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).
|
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.
|
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
|
## 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:
|
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.
|
- `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.
|
- `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"
|
```smart header="Selection end/start vs Range"
|
||||||
There are many ways to select the content, depending on the user agent: mouse, hotkeys, taps on a mobile etc.
|
|
||||||
|
|
||||||
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":
|
E.g. if the user starts selecting with mouse and goes from "Example" to "italic":
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||

|

|
||||||
|
```
|
||||||
That's different from `Range` objects that are always directed forward: the range start can't be after its end.
|
|
||||||
````
|
|
||||||
|
|
||||||
## Selection events
|
## Selection events
|
||||||
|
|
||||||
There are events on to keep track of selection:
|
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.
|
- `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 makes the selection not start.
|
- 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.
|
- `document.onselectionchange` -- whenever a selection changes or starts.
|
||||||
- Please note: this handler can be set only on `document`.
|
- Please note: this handler can be set only on `document`, it tracks all selections in it.
|
||||||
|
|
||||||
### Selection tracking demo
|
### 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
|
```html run height=80
|
||||||
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
|
<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>
|
From <input id="from" disabled> – To <input id="to" disabled>
|
||||||
<script>
|
<script>
|
||||||
document.onselectionchange = function() {
|
document.onselectionchange = function() {
|
||||||
let {anchorNode, anchorOffset, focusNode, focusOffset} = document.getSelection();
|
let selection = document.getSelection();
|
||||||
|
|
||||||
from.value = `${anchorNode && anchorNode.data}:${anchorOffset}`;
|
let {anchorNode, anchorOffset, focusNode, focusOffset} = selection;
|
||||||
to.value = `${focusNode && focusNode.data}:${focusOffset}`;
|
|
||||||
|
// anchorNode and focusNode are text nodes usually
|
||||||
|
from.value = `${anchorNode?.data}, offset ${anchorOffset}`;
|
||||||
|
to.value = `${focusNode?.data}, offset ${focusOffset}`;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Selection getting demo
|
### Selection copying demo
|
||||||
|
|
||||||
To get the whole selection:
|
There are two approaches to copying the selected content:
|
||||||
- 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).
|
|
||||||
|
|
||||||
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
|
```html run height=100
|
||||||
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
|
<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
|
||||||
|
|
||||||
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.
|
- `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.
|
- `removeRange(range)` -- remove `range` from the selection.
|
||||||
- `removeAllRanges()` -- remove all ranges.
|
- `removeAllRanges()` -- remove all ranges.
|
||||||
- `empty()` -- alias to `removeAllRanges`.
|
- `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`.
|
- `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`.
|
- `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.
|
- `deleteFromDocument()` -- remove selected content from the document.
|
||||||
- `containsNode(node, allowPartialContainment = false)` -- checks whether the selection contains `node` (partially if the second argument is `true`)
|
- `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>`:
|
For example, selecting the whole contents of the paragraph `<p>`:
|
||||||
|
|
||||||
|
@ -420,10 +485,10 @@ The same thing using ranges:
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
```smart header="To select, remove the existing selection first"
|
```smart header="To select something, 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.
|
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
|
## Selection in form controls
|
||||||
|
|
1
2-ui/99-ui-misc/02-selection-range/range-hello-1.svg
Normal file
1
2-ui/99-ui-misc/02-selection-range/range-hello-1.svg
Normal 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:'PT Mono';font-weight:700;font-style:normal;src:local('PT MonoBold'),url(/font/PTMonoBold.woff2) format('woff2'),url(/font/PTMonoBold.woff) format('woff'),url(/font/PTMonoBold.ttf) format('truetype')}</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="<p>Hello</p>" fill="#8A704D" font-family="PTMono-Regular, PT Mono" font-size="18" font-weight="normal" letter-spacing="1"><tspan x="5" y="33"><p>Hello</p></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 |
BIN
figures.sketch
BIN
figures.sketch
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue