diff --git a/2-ui/99-ui-misc/01-mutation-observer/article.md b/2-ui/99-ui-misc/01-mutation-observer/article.md new file mode 100644 index 000000000..6a458fa02 --- /dev/null +++ b/2-ui/99-ui-misc/01-mutation-observer/article.md @@ -0,0 +1,266 @@ + +# Mutation observer + +`MutationObserver` is a built-in object that observes a DOM element and fires a callback in case of changes. + +We'll first take a look at the syntax, and then explore a real-world use case, to see where such thing may be useful. + +## Syntax + +`MutationObserver` is easy to use. + +First, we create an observer with a callback-function: + +```js +let observer = new MutationObserver(callback); +``` + +And then attach it to a DOM node: + +```js +observer.observe(node, config); +``` + +`config` is an object with boolean options "what kind of changes to react on": +- `childList` -- changes in the direct children of `node`, +- `subtree` -- in all descendants of `node`, +- `attributes` -- attributes of `node`, +- `attributeFilter` -- an array of attribute names, to observe only selected ones. +- `characterData` -- whether to observe `node.data` (text content), + +Few other options: +- `attributeOldValue` -- if `true`, pass both the old and the new value of attribute to callback (see below), otherwise only the new one (needs `attributes` option), +- `characterDataOldValue` -- if `true`, pass both the old and the new value of `node.data` to callback (see below), otherwise only the new one (needs `characterData` option). + +Then after any changes, the `callback` is executed: changes are passed in the first argument as a list of [MutationRecord](https://dom.spec.whatwg.org/#mutationrecord) objects, and the observer itself as the second argument. + +[MutationRecord](https://dom.spec.whatwg.org/#mutationrecord) objects have properties: + +- `type` -- mutation type, one of + - `"attributes"`: attribute modified + - `"characterData"`: data modified, used for text nodes, + - `"childList"`: child elements added/removed, +- `target` -- where the change occurred: an element for `"attributes"`, or text node for `"characterData"`, or an element for a `"childList"` mutation, +- `addedNodes/removedNodes` -- nodes that were added/removed, +- `previousSibling/nextSibling` -- the previous and next sibling to added/removed nodes, +- `attributeName/attributeNamespace` -- the name/namespace (for XML) of the changed attribute, +- `oldValue` -- the previous value, only for attribute or text changes, if the corresponding option is set `attributeOldValue`/`characterDataOldValue`. + +For example, here's a `
` with a `contentEditable` attribute. That attribute allows us to focus on it and edit. + +```html run +
Click and edit, please
+ + +``` + +If we run this code in the browser, then focus on the given `
` and change the text inside `edit`, `console.log` will show one mutation: + +```js +mutationRecords = [{ + type: "characterData", + oldValue: "edit", + target: , + // other properties empty +}]; +``` + +If we make more complex editing operations, e.g. remove the `edit`, the mutation event may contain multiple mutation records: + +```js +mutationRecords = [{ + type: "childList", + target: , + removedNodes: [], + nextSibling: , + previousSibling: + // other properties empty +}, { + type: "characterData" + target: + // ...mutation details depend on how the browser handles such removal + // it may coalesce two adjacent text nodes "edit " and ", please" into one node + // or it may leave them separate text nodes +}]; +``` + +So, `MutationObserver` allows to react on any changes within DOM subtree. + +## Usage for integration + +When such thing may be useful? + +Imagine the situation when you need to add a third-party script that contains useful functionality, but also does something unwanted, e.g. shows ads `
Unwanted ads
`. + +Naturally, the third-party script provides no mechanisms to remove it. + +Using `MutationObserver`, we can detect when the unwanted element appears in our DOM and remove it. + +There are other situations when a third-party script adds something into our document, and we'd like to detect, when it happens, to adapt our page, dynamically resize something etc. + +`MutationObserver` allows to implement this. + +## Usage for architecture + +There are also situations when `MutationObserver` is good from architectural standpoint. + +Let's say we're making a website about programming. Naturally, articles and other materials may contain source code snippets. + +Such snippet in an HTML markup looks like this: + +```html +... +

+  // here's the code
+  let hello = "world";
+
+... +``` + +Also we'll use a JavaScript highlighting library on our site, e.g. [Prism.js](https://prismjs.com/). A call to `Prism.highlightElem(pre)` examines the contents of such `pre` elements and adds into them special tags and styles for colored syntax highlighting, similar to what you see in examples here, at this page. + +When exactly to run that highlighting method? We can do it on `DOMContentLoaded` event, or at the bottom of the page. At that moment we have our DOM ready, can search for elements `pre[class*="language"]` and call `Prism.highlightElem` on them: + +```js +// highlight all code snippets on the page +document.querySelectorAll('pre[class*="language"]').forEach(Prism.highlightElem); +``` + +Everything's simple so far, right? There are `
` code snippets in HTML, we highlight them.
+
+Now let's go on. Let's say we're going to dynamically fetch materials from a server. We'll study methods for that [later in the tutorial](info:fetch). For now it only matters that we fetch an HTML article from a webserver and display it on demand:
+
+```js
+let article = /* fetch new content from server */
+articleElem.innerHTML = article;
+```
+
+The new `article` HTML may contain code snippets. We need to call `Prism.highlightElem` on them, otherwise they won't get highlighted.
+
+**Where and when to call `Prism.highlightElem` for a dynamically loaded article?**
+
+We could append that call to the code that loads an article, like this:
+
+```js
+let article = /* fetch new content from server */
+articleElem.innerHTML = article;
+
+*!*
+let snippets = articleElem.querySelectorAll('pre[class*="language-"]');
+snippets.forEach(Prism.highlightElem);
+*/!*
+```
+
+...But imagine, we have many places in the code where we load contents: articles, quizzes, forum posts. Do we need to put the highlighting call everywhere? That's not very convenient, and also easy to forget.
+
+And what if the content is loaded by a third-party module? E.g. we have a forum written by someone else, that loads contents dynamically, and we'd like to add syntax highlighting to it. No one likes to patch third-party scripts.
+
+Luckily, there's another option.
+
+We can use `MutationObserver` to automatically detect when code snippets are inserted in the page and highlight them.
+
+So we'll handle the highlighting functionality in one place, relieving us from the need to integrate it.
+
+### Dynamic highlight demo
+
+Here's the working example.
+
+If you run this code, it starts observing the element below and highlighting any code snippets that appear there:
+
+```js run
+let observer = new MutationObserver(mutations => {
+
+  for(let mutation of mutations) {
+    // examine new nodes, is there anything to highlight?
+
+    for(let node of mutation.addedNodes) {
+      // we track only elements, skip other nodes (e.g. text nodes)
+      if (!(node instanceof HTMLElement)) continue;
+
+      // check the inserted element for being a code snippet
+      if (node.matches('pre[class*="language-"]')) {
+        Prism.highlightElement(node);
+      }
+
+      // or maybe there's a code snippet somewhere in its subtree?
+      for(let elem of node.querySelectorAll('pre[class*="language-"]')) {
+        Prism.highlightElement(elem);
+      }
+    }
+  }
+
+});
+
+let demoElem = document.getElementById('highlight-demo');
+
+observer.observe(demoElem, {childList: true, subtree: true});
+```
+
+Here, below, there's an HTML-element and JavaScript that dynamically fills it using `innerHTML`.
+
+Please run the previous code (above, observes that element), and then the code below. You'll see how `MutationObserver` detects and highlights the snippet.
+
+

A demo-element with id="highlight-demo", run the code above to observe it.

+ +The following code populates its `innerHTML`, that causes the `MutationObserver` to react and highlight its contents: + +```js run +let demoElem = document.getElementById('highlight-demo'); + +// dynamically insert content with code snippets +demoElem.innerHTML = `A code snippet is below: +
 let hello = "world!"; 
+
Another one:
+
+
.class { margin: 5px; } 
+
+`; +``` + +Now we have `MutationObserver` that can track all highlighting in observed elements or the whole `document`. We can add/remove code snippets in HTML without thinking about it. + +## Additional methods + +There's a method to stop observing the node: + +- `observer.disconnect()` -- stops the observation. + +When we stop the observing, it might be possible that some changes were not processed by the observer yet. + +- `observer.takeRecords()` -- gets a list of unprocessed mutation records, those that happened, but the callback did not handle them. + +These methods can be used together, like this: + +```js +// we'd like to stop tracking changes +observer.disconnect(); + +// handle unprocessed some mutations +let mutationRecords = observer.takeRecords(); +... +``` + +```smart header="Garbage collection interaction" +Observers use weak references to nodes internally. That is: if a node is removed from DOM, and becomes unreachable, then it becomes garbage collected. + +The mere fact that a DOM node is observed doesn't prevent the garbage collection. +``` + +## Summary + +`MutationObserver` can react on changes in DOM: attributes, added/removed elements, text content. + +We can use it to track changes introduced by other parts of our code, as well as to integrate with third-party scripts. + +`MutationObserver` can track any changes. The config "what to observe" options are used for optimizations, not to spend resources on unneeded callback invocations. diff --git a/2-ui/99-ui-misc/02-selection-range/article.md b/2-ui/99-ui-misc/02-selection-range/article.md new file mode 100644 index 000000000..6f4d6814b --- /dev/null +++ b/2-ui/99-ui-misc/02-selection-range/article.md @@ -0,0 +1,646 @@ +libs: + - d3 + - domtree + +--- + +# Selection and Range + +In this chapter we'll cover selection in the document, as well as selection in form fields, such as ``. + +JavaScript can get the existing selection, select/deselect both as a whole or partially, remove the selected part from the document, wrap it into a tag, and so on. + +You can get ready to use recipes at the end, in "Summary" section. But you'll get much more if you read the whole chapter. The underlying `Range` and `Selection` objects are easy to grasp, and then you'll need no recipes to make them do what you want. + +## Range + +The basic concept of selection is [Range](https://dom.spec.whatwg.org/#ranges): basically, a pair of "boundary points": range start and range end. + +Each point represented as a parent DOM node with the relative offset from its start. If the parent node is an element node, then the offset is a child number, for a text node it's the position in the text. Examples to follow. + +Let's select something. + +First, we can create a range (the constructor has no parameters): + +```js +let range = new Range(); +``` + +Then we can set the selection boundaries using `range.setStart(node, offset)` and `range.setEnd(node, offset)`. + +For example, consider this fragment of HTML: + +```html +

Example: italic and bold

+``` + +Here's its DOM structure, note that here text nodes are important for us: + +
+ + + +Let's select `"Example: italic"`. That's two first children of `

` (counting text nodes): + +![](range-example-p-0-1.svg) + +```html run +

Example: italic and bold

+ + +``` + +- `range.setStart(p, 0)` -- sets the start at the 0th child of `

` (that's the text node `"Example: "`). +- `range.setEnd(p, 2)` -- spans the range up to (but not including) 2nd child of `

` (that's the text node `" and "`, but as the end is not included, so the last selected node is ``). + +Here's a more flexible test stand where you try more variants: + +```html run autorun +

Example: italic and bold

+ +From – To + + +``` + +E.g. selecting from `1` to `4` gives range `italic and bold`. + +![](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. + +### Selecting parts of text nodes + +Let's select the text partially, 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 need to create a range, that: +- starts from position 2 in `

` first child (taking all but two first letters of "Example: ") +- ends at the position 3 in `` first child (taking first three letters of "bold", but no more): + +```html run +

Example: italic and bold

+ + +``` + +The range object has following properties: + +![](range-example-p-2-b-3-range.svg) + +- `startContainer`, `startOffset` -- node and offset of the start, + - in the example above: first text node inside `

` and `2`. +- `endContainer`, `endOffset` -- node and offset of the end, + - in the example above: first text node inside `` and `3`. +- `collapsed` -- boolean, `true` if the range starts and ends on the same point (so there's no content inside the range), + - in the example above: `false` +- `commonAncestorContainer` -- the nearest common ancestor of all nodes within the range, + - in the example above: `

` + +## Range methods + +There are many convenience methods to manipulate ranges. + +Set range start: + +- `setStart(node, offset)` set start at: position `offset` in `node` +- `setStartBefore(node)` set start at: right before `node` +- `setStartAfter(node)` set start at: right after `node` + +Set range end (similar methods): + +- `setEnd(node, offset)` set end at: position `offset` in `node` +- `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.** + +Others: +- `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: + +- `deleteContents()` -- remove range content from the document +- `extractContents()` -- remove range content from the document and return as [DocumentFragment](info:modifying-document#document-fragment) +- `cloneContents()` -- clone range content and return as [DocumentFragment](info:modifying-document#document-fragment) +- `insertNode(node)` -- insert `node` into the document at the beginning of the range +- `surroundContents(node)` -- wrap `node` around range content. For this to work, the range must contain both opening and closing tags for all elements inside it: no partial ranges like `abc`. + +With these methods we can do basically anything with selected nodes. + +Here's the test stand to see them in action: + +```html run autorun height=260 +Click buttons to run methods on the selection, "resetExample" to reset it. + +

Example: italic and bold

+ +

+ +``` + +There also exist methods to compare ranges, but these are rarely used. When you need them, please refer to the [spec](https://dom.spec.whatwg.org/#interface-range) or [MDN manual](https://developer.mozilla.org/en-US/docs/Web/API/Range). + + +## Selection + +`Range` is a generic object for managing selection ranges. We may create such 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). + +Here's a screenshot of a selection with 3 ranges, made in Firefox: + +![](selection-firefox.svg) + +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. + +## Selection properties + +Similar to a range, a selection has a start, called "anchor", and the end, called "focus". + +The main selection properties are: + +- `anchorNode` -- the node where the selection starts, +- `anchorOffset` -- the offset in `anchorNode` where the selection starts, +- `focusNode` -- the node where the selection ends, +- `focusOffset` -- the offset in `focusNode` where the selection ends, +- `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="Selection end may be in the document before start" +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". + +If the start (anchor) of the selection goes in the document before the end (focus), this selection is said to have "forward" direction. + +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: + +![](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`. + +### Selection tracking demo + +Here's a small demo that shows selection boundaries dynamically as it changes: + +```html run height=80 +

Select me: italic and bold

+ +From – To + +``` + +### Selection getting 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). + +And here's the demo of getting the selection both as text and as DOM nodes: + +```html run height=100 +

Select me: italic and bold

+ +Cloned: +
+As text: + + +``` + +## Selection methods + +Selection methods to add/remove ranges: + +- `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`: + +- `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`. +- `collapseToStart()` - collapse (replace with an empty range) to selection start, +- `collapseToEnd()` - collapse to selection end, +- `extend(node, offset)` - move focus of the selection to the given `node`, position `offset`, +- `setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)` - replace selection range with the given start `anchorNode/anchorOffset` and end `focusNode/focusOffset`. All content in-between them is selected. +- `selectAllChildren(node)` -- select all children of the `node`. +- `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 example, selecting the whole contents of the paragraph `

`: + +```html run +

Select me: italic and bold

+ + +``` + +The same thing using ranges: + +```html run +

Select me: italic and bold

+ + +``` + +```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. + +The exception is some selection methods, that replace the existing selection, like `setBaseAndExtent`. +``` + +## Selection in form controls + +Form elements, such as `input` and `textarea` provide [special API for selection](https://html.spec.whatwg.org/#textFieldSelection), without `Selection` or `Range` objects. As an input value is a pure text, not HTML, there's no need for such objects, everything's much simpler. + +Properties: +- `input.selectionStart` -- position of selection start (writeable), +- `input.selectionEnd` -- position of selection end (writeable), +- `input.selectionDirection` -- selection direction, one of: "forward", "backward" or "none" (if e.g. selected with a double mouse click), + +Events: +- `input.onselect` -- triggers when something is selected. + +Methods: + +- `input.select()` -- selects everything in the text control (can be `textarea` instead of `input`), +- `input.setSelectionRange(start, end, [direction])` -- change the selection to span from position `start` till `end`, in the given direction (optional). +- `input.setRangeText(replacement, [start], [end], [selectionMode])` -- replace a range of text with the new text. + + Optional arguments `start` and `end`, if provided, set the range start and end, otherwise user selection is used. + + The last argument, `selectionMode`, determines how the selection will be set after the text has been replaced. The possible values are: + + - `"select"` -- the newly inserted text will be selected. + - `"start"` -- the selection range collapses just before the inserted text (the cursor will be immediately before it). + - `"end"` -- the selection range collapses just after the inserted text (the cursor will be right after it). + - `"preserve"` -- attempts to preserve the selection. This is the default. + +Now let's see these methods in action. + +### Example: tracking selection + +For example, this code uses `onselect` event to track selection: + +```html run autorun + +
+From – To + + +``` + +Please note: +- `onselect` triggers when something is selected, but not when the selection is removed. +- `document.onselectionchange` event should not trigger for selections inside a form control, according to the [spec](https://w3c.github.io/selection-api/#dfn-selectionchange), as it's not related to `document` selection and ranges. Some browsers generate it, but we shouldn't rely on it. + + +### Example: moving cursor + +We can change `selectionStart` and `selectionEnd`, that sets the selection. + +An important edge case is when `selectionStart` and `selectionEnd` equal each other. Then it's exactly the cursor position. Or, to rephrase, when nothing is selected, the selection is collapsed at the cursor position. + +So, by setting `selectionStart` and `selectionEnd` to the same value, we move the cursor. + +For example: + +```html run autorun + + + +``` + +### Example: modifying selection + +To modify the content of the selection, we can use `input.setRangeText()` method. Of course, we can read `selectionStart/End` and, with the knowledge of the selection, change the corresponding substring of `value`, but `setRangeText` is more powerful and often more convenient. + +That's a somewhat complex method. In its simplest one-argument form it replaces the user selected range and removes the selection. + +For example, here the user selection will be wrapped by `*...*`: + +```html run autorun + + + + +``` + +With more arguments, we can set range `start` and `end`. + +In this example we find `"THIS"` in the input text, replace it and keep the replacement selected: + +```html run autorun + + + + +``` + +### Example: insert at cursor + +If nothing is selected, or we use equal `start` and `end` in `setRangeText`, then the new text is just inserted, nothing is removed. + +We can also insert something "at the cursor" using `setRangeText`. + +Here's a button that inserts `"HELLO"` at the cursor position and puts the cursor immediately after it. If the selection is not empty, then it gets replaced (we can detect it by comparing `selectionStart!=selectionEnd` and do something else instead): + +```html run autorun + + + + +``` + + +## Making unselectable + +To make something unselectable, there are three ways: + +1. Use CSS property `user-select: none`. + + ```html run + +
Selectable
Unselectable
Selectable
+ ``` + + This doesn't allow the selection to start at `elem`. But the user may start the selection elsewhere and include `elem` into it. + + Then `elem` will become a part of `document.getSelection()`, so the selection actually happens, but its content is usually ignored in copy-paste. + + +2. Prevent default action in `onselectstart` or `mousedown` events. + + ```html run +
Selectable
Unselectable
Selectable
+ + + ``` + + This prevents starting the selection on `elem`, but the visitor may start it at another element, then extend to `elem`. + + That's convenient when there's another event handler on the same action that triggers the select (e.g. `mousedown`). So we disable the selection to avoid conflict, still allowing `elem` contents to be copied. + +3. We can also clear the selection post-factum after it happens with `document.getSelection().empty()`. That's rarely used, as this causes unwanted blinking as the selection appears-disappears. + +## References + +- [DOM spec: Range](https://dom.spec.whatwg.org/#ranges) +- [Selection API](https://www.w3.org/TR/selection-api/#dom-globaleventhandlers-onselectstart) +- [HTML spec: APIs for the text control selections](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#textFieldSelection) + + +## Summary + +We covered two different APIs for selections: + +1. For document: `Selection` and `Range` objects. +2. For `input`, `textarea`: additional methods and properties. + +The second API is very simple, as it works with text. + +The most used recipes are probably: + +1. Getting the selection: + ```js run + let selection = document.getSelection(); + + let cloned = /* element to clone the selected nodes to */; + + // then apply Range methods to selection.getRangeAt(0) + // or, like here, to all ranges to support multi-select + for (let i = 0; i < selection.rangeCount; i++) { + cloned.append(selection.getRangeAt(i).cloneContents()); + } + ``` +2. Setting the selection: + ```js run + let selection = document.getSelection(); + + // directly: + selection.setBaseAndExtent(...from...to...); + + // or we can create a range and: + selection.removeAllRanges(); + selection.addRange(range); + ``` + +And finally, about the cursor. The cursor position in editable elements, like `