├── .editorconfig ├── .gitattributes ├── .gitignore ├── .npmrc ├── .travis.yml ├── index.ts ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tabs 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | test.js linguist-detectable=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.js 3 | index.d.ts 4 | *.map 5 | .test.js 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | addons: 5 | firefox: 65.0 6 | apt: 7 | packages: 8 | - xvfb 9 | install: 10 | - export DISPLAY=':99.0' 11 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 12 | - npm install 13 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * `index` means the character-index of a node relative to its main element, 3 | * regardless of other HTML elements in between. 4 | * Example: "Hello World!" 5 | * The tag `b` and the exclamation mark are at index 11 6 | */ 7 | function getIndex(container: Node & ParentNode, target: Node): number { 8 | let index = 0; 9 | do { 10 | while (target.previousSibling) { 11 | index += target.previousSibling.textContent!.length; 12 | target = target.previousSibling; 13 | } 14 | 15 | target = target.parentElement!; 16 | } while (target && target !== container); 17 | 18 | return index; 19 | } 20 | 21 | // Get text node at an character index of an Element. 22 | // Only needed because .setStart only accepts a character index relative to a single TextNode 23 | function getNodeAtIndex(container: Node, index: number): [Node, number] { 24 | let relativeIndex = index; 25 | let cursor = container; 26 | while (cursor && cursor.firstChild) { 27 | cursor = cursor.firstChild; 28 | while (cursor && cursor.textContent!.length < relativeIndex) { 29 | relativeIndex -= cursor.textContent!.length; 30 | if (cursor.nextSibling) { 31 | cursor = cursor.nextSibling; 32 | } 33 | } 34 | } 35 | 36 | return [cursor, relativeIndex]; 37 | } 38 | 39 | // Get Range that starts/ends across multiple/nested TextNodes of an Element. 40 | // Only needed because .setStart only accepts a character index relative to a single TextNode 41 | function getSmartIndexRange(node: Node & ParentNode, start: number, end: number): Range { 42 | const range = document.createRange(); 43 | range.setStart(...getNodeAtIndex(node, start)); 44 | range.setEnd(...getNodeAtIndex(node, end)); 45 | return range; 46 | } 47 | 48 | /** 49 | * @example 50 | * 51 | * Merge two elements like: 52 | *
a, b, c
53 | * and: 54 | *
a, b, c
55 | * into: 56 | *
a, b, c
57 | * 58 | * Useful when `target` is subject to transforms that lose its elements and you want to restore them. 59 | */ 60 | export = function (target: Node & ParentNode, source: Node & ParentNode): void { 61 | if (target.textContent !== source.textContent) { 62 | throw new Error('`target` and `source` must have matching `textContent`'); 63 | } 64 | 65 | for (const child of source.querySelectorAll('*')) { 66 | const textIndex = getIndex(source, child); 67 | const newEl = child.cloneNode() as typeof child; 68 | const contentsRange = getSmartIndexRange( 69 | target, 70 | textIndex, 71 | textIndex + child.textContent!.length 72 | ); 73 | newEl.append(contentsRange.extractContents()); 74 | contentsRange.insertNode(newEl); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Federico Brigante (bfred.it) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zip-text-nodes", 3 | "version": "1.0.0", 4 | "description": "Merge the DOM of 2 elements with the same textContent", 5 | "keywords": [ 6 | "assign", 7 | "browser", 8 | "dom", 9 | "element", 10 | "merge", 11 | "textnode" 12 | ], 13 | "repository": "fregante/zip-text-nodes", 14 | "license": "MIT", 15 | "files": [ 16 | "index.js", 17 | "index.d.ts" 18 | ], 19 | "scripts": { 20 | "build": "tsc", 21 | "build-test": "browserify -t [ babelify --plugins [ @babel/plugin-transform-react-jsx ] ] test.js > .test.js", 22 | "prepare": "tsc", 23 | "test": "npm-run-all --silent prepare build-test --parallel test:*", 24 | "test:blink": "cat .test.js | tape-run", 25 | "test:gecko": "if [ $CI ]; then cat .test.js | tape-run --browser firefox; fi", 26 | "test:lint": "xo", 27 | "watch": "tsc --watch" 28 | }, 29 | "xo": { 30 | "envs": [ 31 | "browser" 32 | ], 33 | "extensions": [ 34 | "ts" 35 | ], 36 | "overrides": [ 37 | { 38 | "files": "**/*.ts", 39 | "extends": "xo-typescript" 40 | } 41 | ], 42 | "rules": { 43 | "no-unused-vars": [ 44 | "error", 45 | { 46 | "varsIgnorePattern": "^React$" 47 | } 48 | ] 49 | } 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.4.5", 53 | "@babel/plugin-transform-react-jsx": "^7.3.0", 54 | "@sindresorhus/tsconfig": "^0.3.0", 55 | "@typescript-eslint/eslint-plugin": "^1.9.0", 56 | "@typescript-eslint/parser": "^1.13.0", 57 | "ava": "^1.4.1", 58 | "babelify": "^10.0.0", 59 | "browserify": "^16.2.3", 60 | "dom-chef": "^3.6.0", 61 | "eslint-config-xo-typescript": "^0.12.0", 62 | "npm-run-all": "^4.1.5", 63 | "tape": "^4.10.1", 64 | "tape-run": "^6.0.0", 65 | "typescript": "^3.4.5", 66 | "xo": "*" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # zip-text-nodes [![][badge-gzip]][link-bundlephobia] 2 | 3 | [badge-gzip]: https://img.shields.io/bundlephobia/minzip/zip-text-nodes.svg?label=gzipped 4 | [link-bundlephobia]: https://bundlephobia.com/result?p=zip-text-nodes 5 | 6 | > Merge the DOM of 2 elements with the same textContent. 7 | 8 | Given 2 elements: 9 | 10 | ```html 11 | Hello, world! 12 | ``` 13 | 14 | and: 15 | 16 | ```html 17 | Hello, world! 18 | ``` 19 | 20 | they are merged into: 21 | 22 | ```html 23 | Hello, world! 24 | ``` 25 | 26 | This can be useful when running some transformations on the content of an element and successively merging the results or restoring the original markup. 27 | 28 | ```js 29 | const base = <>I live in Italy; 30 | const grammar = highlightVerb(base); 31 | // <>I live in Italy // e.g. the link was lost 32 | 33 | zipTextNodes(base, grammar); 34 | // <>I live in Italy 35 | // The new `em` is copied from `grammar` to `base` 36 | ``` 37 | 38 | Supports overlapping and nested elements. 39 | 40 | 41 | ## Install 42 | 43 | ``` 44 | npm install zip-text-nodes 45 | ``` 46 | 47 | 48 | ## Setup 49 | 50 | ```js 51 | const zipTextNodes = require('zip-text-nodes'); 52 | ``` 53 | 54 | ```js 55 | import zipTextNodes from 'zip-text-nodes'; 56 | ``` 57 | 58 | 59 | ## API 60 | 61 | ### zipTextNodes(target, source) 62 | 63 | #### target 64 | 65 | Type: `Element` `DocumentFragment` 66 | 67 | The element into which the new children are copied. This element is modified. 68 | 69 | #### source 70 | 71 | Type: `Element` `DocumentFragment` 72 | 73 | The element from which the new children are copied. 74 | 75 | # Related 76 | 77 | - [insert-text-textarea](https://github.com/fregante/insert-text-textarea) - Insert text in a textarea (supports Firefox and Undo). 78 | - [fit-textarea](https://github.com/fregante/fit-textarea) - Automatically expand a `