├── .editorconfig ├── .gitignore ├── .npmrc ├── .travis.yml ├── .yo-rc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── custom-typings.d.ts ├── demo ├── demo.component.ts ├── demo.module.ts ├── entry.ts └── index.ejs ├── karma.conf.ts ├── package-lock.json ├── package.json ├── src ├── highlight-tag.interface.ts ├── index.ts ├── text-input-element.directive.ts ├── text-input-highlight-container.directive.ts ├── text-input-highlight.component.ts ├── text-input-highlight.module.ts └── text-input-highlight.scss ├── test ├── entry.ts └── text-input-highlight.component.spec.ts ├── tsconfig-compodoc.json ├── tsconfig-ngc.json ├── tsconfig.json ├── tslint.json ├── webpack.config.ts └── webpack.config.umd.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | node_modules/ 4 | coverage/ 5 | npm-debug.log 6 | dist/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-prefix=^ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '10' 5 | 6 | script: npm test 7 | 8 | notifications: 9 | email: false 10 | 11 | cache: 12 | directories: 13 | - node_modules 14 | 15 | after_success: 16 | - npm run codecov 17 | 18 | addons: 19 | chrome: stable 20 | 21 | dist: trusty 22 | 23 | sudo: required 24 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-angular-library": { 3 | "githubUsername": "mattlewis92", 4 | "githubRepoName": "angular-text-input-highlight", 5 | "npmModuleName": "angular-text-input-highlight", 6 | "moduleGlobal": "angularTextInputHighlight", 7 | "ngModuleName": "TextInputHighlightModule", 8 | "selectorPrefix": "mwl", 9 | "projectTitle": "angular text input highlight", 10 | "projectDescription": "A component that can highlight parts of text in an input or textarea. Useful for displaying mentions etc", 11 | "authorName": "Matt Lewis", 12 | "packageManager": "npm" 13 | } 14 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [1.4.3](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.4.2...v1.4.3) (2020-10-27) 7 | 8 | 9 | ### Bug Fixes 10 | 11 | * enable word-wrap to fix positioning of highlight elements ([ff55187](https://github.com/mattlewis92/angular-text-input-highlight/commit/ff55187)) 12 | 13 | 14 | 15 | 16 | ## [1.4.2](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.4.1...v1.4.2) (2020-03-05) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * allow latest angular versions in peer dependencies ([0f07483](https://github.com/mattlewis92/angular-text-input-highlight/commit/0f07483)) 22 | 23 | 24 | 25 | 26 | ## [1.4.1](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.4.0...v1.4.1) (2020-02-12) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * update highlight positioning when resizing textarea ([6f8d633](https://github.com/mattlewis92/angular-text-input-highlight/commit/6f8d633)) 32 | 33 | 34 | 35 | 36 | # [1.4.0](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.3.1...v1.4.0) (2018-06-20) 37 | 38 | 39 | ### Features 40 | 41 | * add original MouseEvent to tag events ([#5](https://github.com/mattlewis92/angular-text-input-highlight/issues/5)) ([8985b76](https://github.com/mattlewis92/angular-text-input-highlight/commit/8985b76)) 42 | 43 | 44 | 45 | 46 | ## [1.3.1](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.3.0...v1.3.1) (2018-05-05) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * allow angular 6 peer dependency ([4a04f5d](https://github.com/mattlewis92/angular-text-input-highlight/commit/4a04f5d)) 52 | 53 | 54 | 55 | 56 | # [1.3.0](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.2.2...v1.3.0) (2017-11-13) 57 | 58 | 59 | ### Features 60 | 61 | * introduce textInputValue input ([7980ee1](https://github.com/mattlewis92/angular-text-input-highlight/commit/7980ee1)) 62 | 63 | 64 | 65 | 66 | ## [1.2.2](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.2.1...v1.2.2) (2017-11-09) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * make long tags wrap multiple lines ([8488879](https://github.com/mattlewis92/angular-text-input-highlight/commit/8488879)) 72 | 73 | 74 | 75 | 76 | ## [1.2.1](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.2.0...v1.2.1) (2017-11-02) 77 | 78 | 79 | ### Bug Fixes 80 | 81 | * add support for angular 5 ([faf48b7](https://github.com/mattlewis92/angular-text-input-highlight/commit/faf48b7)) 82 | 83 | 84 | 85 | 86 | # [1.2.0](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.1.1...v1.2.0) (2017-10-11) 87 | 88 | 89 | ### Bug Fixes 90 | 91 | * handle the component being destroyed before the initial render is complete ([cb2b3c1](https://github.com/mattlewis92/angular-text-input-highlight/commit/cb2b3c1)) 92 | 93 | 94 | ### Features 95 | 96 | * expose refresh method as part of the public api ([da95817](https://github.com/mattlewis92/angular-text-input-highlight/commit/da95817)) 97 | 98 | 99 | 100 | 101 | ## [1.1.1](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.1.0...v1.1.1) (2017-09-26) 102 | 103 | 104 | ### Bug Fixes 105 | 106 | * recompute highlight element size when the window is resized ([70a5ddc](https://github.com/mattlewis92/angular-text-input-highlight/commit/70a5ddc)) 107 | 108 | 109 | 110 | 111 | # [1.1.0](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.0.4...v1.1.0) (2017-09-01) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * allow html special characters in the textarea value ([d86f155](https://github.com/mattlewis92/angular-text-input-highlight/commit/d86f155)) 117 | 118 | 119 | ### Features 120 | 121 | * add tagClick, tagMouseEnter and tagMouseLeave outputs ([2fb7817](https://github.com/mattlewis92/angular-text-input-highlight/commit/2fb7817)) 122 | 123 | 124 | 125 | 126 | ## [1.0.4](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.0.3...v1.0.4) (2017-08-14) 127 | 128 | 129 | ### Bug Fixes 130 | 131 | * tags should wrap multiple lines ([f3ba5ee](https://github.com/mattlewis92/angular-text-input-highlight/commit/f3ba5ee)) 132 | 133 | 134 | 135 | 136 | ## [1.0.3](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.0.2...v1.0.3) (2017-08-07) 137 | 138 | 139 | ### Bug Fixes 140 | 141 | * **ie:** fix styles in IE 11 and edge ([21a6abd](https://github.com/mattlewis92/angular-text-input-highlight/commit/21a6abd)) 142 | 143 | 144 | 145 | 146 | ## [1.0.2](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.0.1...v1.0.2) (2017-07-22) 147 | 148 | 149 | ### Bug Fixes 150 | 151 | * add spacing between tags on adjoining rows ([ca65707](https://github.com/mattlewis92/angular-text-input-highlight/commit/ca65707)) 152 | * delay copying textarea styles to give it time to render ([fcf8abe](https://github.com/mattlewis92/angular-text-input-highlight/commit/fcf8abe)) 153 | 154 | 155 | 156 | 157 | ## [1.0.1](https://github.com/mattlewis92/angular-text-input-highlight/compare/v1.0.0...v1.0.1) (2017-07-22) 158 | 159 | 160 | ### Bug Fixes 161 | 162 | * remove fixed width from textarea ([89c638b](https://github.com/mattlewis92/angular-text-input-highlight/commit/89c638b)) 163 | 164 | 165 | 166 | 167 | # 1.0.0 (2017-07-22) 168 | 169 | 170 | ### Features 171 | 172 | * initial implementation ([b7f2955](https://github.com/mattlewis92/angular-text-input-highlight/commit/b7f2955)) 173 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Matt Lewis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # angular text input highlight 2 | [![Build Status](https://travis-ci.org/mattlewis92/angular-text-input-highlight.svg?branch=master)](https://travis-ci.org/mattlewis92/angular-text-input-highlight) 3 | [![codecov](https://codecov.io/gh/mattlewis92/angular-text-input-highlight/branch/master/graph/badge.svg)](https://codecov.io/gh/mattlewis92/angular-text-input-highlight) 4 | [![npm version](https://badge.fury.io/js/angular-text-input-highlight.svg)](http://badge.fury.io/js/angular-text-input-highlight) 5 | [![devDependency Status](https://david-dm.org/mattlewis92/angular-text-input-highlight/dev-status.svg)](https://david-dm.org/mattlewis92/angular-text-input-highlight?type=dev) 6 | [![GitHub issues](https://img.shields.io/github/issues/mattlewis92/angular-text-input-highlight.svg)](https://github.com/mattlewis92/angular-text-input-highlight/issues) 7 | [![GitHub stars](https://img.shields.io/github/stars/mattlewis92/angular-text-input-highlight.svg)](https://github.com/mattlewis92/angular-text-input-highlight/stargazers) 8 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/mattlewis92/angular-text-input-highlight/master/LICENSE) 9 | 10 | ## Demo 11 | https://mattlewis92.github.io/angular-text-input-highlight/ 12 | 13 | ## Table of contents 14 | 15 | - [About](#about) 16 | - [Installation](#installation) 17 | - [Documentation](#documentation) 18 | - [Development](#development) 19 | - [License](#license) 20 | 21 | ## About 22 | 23 | A component that can highlight parts of text in a textarea. Useful for displaying mentions etc 24 | 25 | ## Installation 26 | 27 | Install through npm: 28 | ``` 29 | npm install --save angular-text-input-highlight 30 | ``` 31 | 32 | Include the stylesheet somewhere in your app: 33 | ``` 34 | node_modules/angular-text-input-highlight/text-input-highlight.css 35 | ``` 36 | 37 | Then include in your apps module: 38 | 39 | ```typescript 40 | import { NgModule } from '@angular/core'; 41 | import { TextInputHighlightModule } from 'angular-text-input-highlight'; 42 | 43 | @NgModule({ 44 | imports: [ 45 | TextInputHighlightModule 46 | ] 47 | }) 48 | export class MyModule {} 49 | ``` 50 | 51 | Finally use in one of your apps components: 52 | ```typescript 53 | import { Component, ViewEncapsulation } from '@angular/core'; 54 | import { HighlightTag } from 'angular-text-input-highlight'; 55 | 56 | @Component({ 57 | template: ` 58 |
59 | 64 | 67 | 68 |
69 | `, 70 | styles: [ 71 | ` 72 | // by default you won't see the highlighted tags until 73 | // you add a CSS class with a background color 74 | .bg-blue { 75 | background-color: lightblue; 76 | } 77 | .bg-pink { 78 | background-color: lightcoral; 79 | } 80 | ` 81 | ], 82 | encapsulation: ViewEncapsulation.None 83 | }) 84 | class MyComponent { 85 | 86 | text = 'this is some text'; 87 | 88 | tags: HighlightTag[] = [{ 89 | indices: { start: 8, end: 12 }, 90 | cssClass: 'bg-blue', 91 | data: { user: { id: 1 } } 92 | }]; 93 | 94 | } 95 | ``` 96 | 97 | You may also find it useful to view the [demo source](https://github.com/mattlewis92/angular-text-input-highlight/blob/master/demo/demo.component.ts). 98 | 99 | ### Usage without a module bundler 100 | ``` 101 | 102 | 105 | ``` 106 | 107 | ## Documentation 108 | All documentation is auto-generated from the source via [compodoc](https://compodoc.github.io/compodoc/) and can be viewed here: 109 | https://mattlewis92.github.io/angular-text-input-highlight/docs/ 110 | 111 | ## Credits 112 | This component borrows heavily from the ideas of the [ui-mention](https://github.com/angular-ui/ui-mention) package. 113 | 114 | ## Development 115 | 116 | ### Prepare your environment 117 | * Install [Node.js](http://nodejs.org/) and NPM 118 | * Install local dev dependencies: `npm install` while current directory is this repo 119 | 120 | ### Development server 121 | Run `npm start` to start a development server on port 8000 with auto reload + tests. 122 | 123 | ### Testing 124 | Run `npm test` to run tests once or `npm run test:watch` to continually run tests. 125 | 126 | ### Release 127 | ```bash 128 | npm run release 129 | ``` 130 | 131 | ## License 132 | 133 | MIT 134 | -------------------------------------------------------------------------------- /custom-typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'fork-ts-checker-webpack-plugin'; 2 | declare module 'webpack-angular-externals'; 3 | declare module 'webpack-rxjs-externals'; -------------------------------------------------------------------------------- /demo/demo.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 2 | import { HighlightTag } from '../src/'; 3 | 4 | @Component({ 5 | selector: 'mwl-demo-app', 6 | template: ` 7 |
8 | 16 | 23 | 24 |
25 |
26 |
27 | Tag clicked! {{ tagClicked.data }} 28 |
29 | `, 30 | styles: [ 31 | ` 32 | .bg-blue { 33 | background-color: lightblue; 34 | } 35 | .bg-pink { 36 | background-color: lightcoral; 37 | } 38 | .bg-blue-dark { 39 | background-color: #86c5da; 40 | } 41 | .bg-pink-dark { 42 | background-color: #eb5252; 43 | } 44 | textarea { 45 | width: 500px; 46 | } 47 | ` 48 | ], 49 | encapsulation: ViewEncapsulation.None 50 | }) 51 | export class DemoComponent implements OnInit { 52 | text: string = 'Hello @mattlewis92 how are you today?\n\nLook I have a #different background color!\n\n@angular is pretty awesome!'; 53 | 54 | tags: HighlightTag[] = []; 55 | 56 | tagClicked: HighlightTag; 57 | 58 | ngOnInit(): void { 59 | this.addTags(); 60 | } 61 | 62 | addTags() { 63 | this.tags = []; 64 | const matchMentions = /(@\w+) ?/g; 65 | let mention; 66 | // tslint:disable-next-line 67 | while ((mention = matchMentions.exec(this.text))) { 68 | this.tags.push({ 69 | indices: { 70 | start: mention.index, 71 | end: mention.index + mention[1].length 72 | }, 73 | data: mention[1] 74 | }); 75 | } 76 | 77 | const matchHashtags = /(#\w+) ?/g; 78 | let hashtag; 79 | // tslint:disable-next-line 80 | while ((hashtag = matchHashtags.exec(this.text))) { 81 | this.tags.push({ 82 | indices: { 83 | start: hashtag.index, 84 | end: hashtag.index + hashtag[1].length 85 | }, 86 | cssClass: 'bg-pink', 87 | data: hashtag[1] 88 | }); 89 | } 90 | } 91 | 92 | addDarkClass(elm: HTMLElement) { 93 | if (elm.classList.contains('bg-blue')) { 94 | elm.classList.add('bg-blue-dark'); 95 | } else if (elm.classList.contains('bg-pink')) { 96 | elm.classList.add('bg-pink-dark'); 97 | } 98 | } 99 | 100 | removeDarkClass(elm: HTMLElement) { 101 | elm.classList.remove('bg-blue-dark'); 102 | elm.classList.remove('bg-pink-dark'); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /demo/demo.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { TextInputHighlightModule } from '../src'; 5 | import { DemoComponent } from './demo.component'; 6 | 7 | @NgModule({ 8 | declarations: [DemoComponent], 9 | imports: [BrowserModule, FormsModule, TextInputHighlightModule], 10 | bootstrap: [DemoComponent] 11 | }) 12 | export class DemoModule {} 13 | -------------------------------------------------------------------------------- /demo/entry.ts: -------------------------------------------------------------------------------- 1 | import '../src/text-input-highlight.scss'; 2 | import 'core-js'; 3 | import 'zone.js/dist/zone'; 4 | import { enableProdMode } from '@angular/core'; 5 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 6 | import { DemoModule } from './demo.module'; 7 | 8 | declare const ENV: string; 9 | if (ENV === 'production') { 10 | enableProdMode(); 11 | } 12 | 13 | platformBrowserDynamic().bootstrapModule(DemoModule); 14 | -------------------------------------------------------------------------------- /demo/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | angular text input highlight 9 | 14 | 15 | 16 | 17 | 24 | 25 | 37 | 38 |
39 | Loading demo... 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /karma.conf.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import * as path from 'path'; 3 | import * as ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 4 | 5 | export default (config: any) => { 6 | 7 | config.set({ 8 | 9 | // base path that will be used to resolve all patterns (eg. files, exclude) 10 | basePath: './', 11 | 12 | // frameworks to use 13 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 14 | frameworks: ['mocha'], 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'test/entry.ts' 19 | ], 20 | 21 | // preprocess matching files before serving them to the browser 22 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 23 | preprocessors: { 24 | 'test/entry.ts': ['webpack', 'sourcemap'] 25 | }, 26 | 27 | webpack: { 28 | resolve: { 29 | extensions: ['.ts', '.js'] 30 | }, 31 | module: { 32 | rules: [{ 33 | test: /\.ts$/, 34 | loader: 'tslint-loader', 35 | exclude: /node_modules/, 36 | enforce: 'pre', 37 | options: { 38 | emitErrors: config.singleRun, 39 | failOnHint: config.singleRun 40 | } 41 | }, { 42 | test: /\.ts$/, 43 | loader: 'ts-loader', 44 | exclude: /node_modules/, 45 | options: { 46 | transpileOnly: !config.singleRun 47 | } 48 | }, { 49 | test: /src(\\|\/).+\.ts$/, 50 | exclude: /(node_modules|\.spec\.ts$)/, 51 | loader: 'istanbul-instrumenter-loader', 52 | enforce: 'post' 53 | }] 54 | }, 55 | plugins: [ 56 | new webpack.SourceMapDevToolPlugin({ 57 | filename: null, 58 | test: /\.(ts|js)($|\?)/i 59 | }), 60 | new webpack.ContextReplacementPlugin( 61 | /angular(\\|\/)core(\\|\/)@angular/, 62 | path.join(__dirname, 'src') 63 | ), 64 | ...(config.singleRun ? [ 65 | new webpack.NoEmitOnErrorsPlugin() 66 | ] : [ 67 | new ForkTsCheckerWebpackPlugin({ 68 | watch: ['./src', './test'] 69 | }) 70 | ]) 71 | ] 72 | }, 73 | 74 | mochaReporter: { 75 | showDiff: true, 76 | output: 'autowatch' 77 | }, 78 | 79 | coverageIstanbulReporter: { 80 | reports: ['text-summary', 'html', 'lcovonly'], 81 | fixWebpackSourcePaths: true 82 | }, 83 | 84 | mime: { 85 | 'text/x-typescript': ['ts'] 86 | }, 87 | 88 | // test results reporter to use 89 | // possible values: 'dots', 'progress' 90 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 91 | reporters: ['mocha', 'coverage-istanbul'], 92 | 93 | // level of logging 94 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 95 | logLevel: config.LOG_INFO, 96 | 97 | // start these browsers 98 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 99 | browsers: ['ChromeHeadless'] 100 | 101 | }); 102 | 103 | }; 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-text-input-highlight", 3 | "version": "1.4.3", 4 | "description": "A component that can highlight parts of text in a textarea. Useful for displaying mentions etc", 5 | "main": "./bundles/angular-text-input-highlight.umd.js", 6 | "module": "./index.js", 7 | "typings": "./index.d.ts", 8 | "scripts": { 9 | "start": "concurrently --raw \"webpack-dev-server --open\" \"npm run test:watch\"", 10 | "build:demo": "webpack -p", 11 | "build:umd": "webpack --config webpack.config.umd.ts", 12 | "build:ngc": "ngc -p tsconfig-ngc.json", 13 | "build:sass": "node-sass src/text-input-highlight.scss dist/text-input-highlight.css", 14 | "build:dist": "npm run build:umd && npm run build:ngc && npm run build:sass", 15 | "build:clean": "del-cli dist", 16 | "test": "karma start --single-run && npm run build:dist && npm run build:clean", 17 | "test:watch": "karma start --auto-watch", 18 | "commit": "git-cz", 19 | "compodoc": "compodoc -p tsconfig-compodoc.json -d docs --disableGraph --disableCoverage --disablePrivate --disableInternal --disableLifeCycleHooks --disableProtected", 20 | "gh-pages": "git checkout gh-pages && git merge master --no-edit --no-ff && npm run build:demo && npm run compodoc && git add . && git commit -m \"chore: build demo and docs\" && git push && git checkout master", 21 | "copyfiles": "copyfiles package.json LICENSE README.md CHANGELOG.md dist", 22 | "prerelease": "npm test", 23 | "release:git": "standard-version && git push --follow-tags origin master", 24 | "release:npm": "npm run build:dist && npm run copyfiles && npm publish dist", 25 | "release": "npm run release:git && npm run release:npm", 26 | "postrelease": "npm run build:clean && npm run gh-pages", 27 | "codecov": "cat coverage/lcov.info | codecov", 28 | "prettier": "prettier --single-quote --parser typescript --write" 29 | }, 30 | "lint-staged": { 31 | "{demo,src,test}/**/*.ts": [ 32 | "npm run prettier", 33 | "git add" 34 | ] 35 | }, 36 | "config": { 37 | "commitizen": { 38 | "path": "@commitlint/prompt" 39 | } 40 | }, 41 | "commitlint": { 42 | "extends": [ 43 | "@commitlint/config-conventional" 44 | ], 45 | "rules": { 46 | "subject-case": [ 47 | 0 48 | ] 49 | } 50 | }, 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/mattlewis92/angular-text-input-highlight.git" 54 | }, 55 | "keywords": [ 56 | "angular4", 57 | "angular2", 58 | "angular", 59 | "highlight", 60 | "textarea" 61 | ], 62 | "author": "Matt Lewis", 63 | "license": "MIT", 64 | "bugs": { 65 | "url": "https://github.com/mattlewis92/angular-text-input-highlight/issues" 66 | }, 67 | "homepage": "https://github.com/mattlewis92/angular-text-input-highlight#readme", 68 | "devDependencies": { 69 | "@angular/common": "^4.4.4", 70 | "@angular/compiler": "^4.4.4", 71 | "@angular/compiler-cli": "^4.4.4", 72 | "@angular/core": "^4.4.4", 73 | "@angular/forms": "^4.4.4", 74 | "@angular/language-service": "^4.4.4", 75 | "@angular/platform-browser": "^4.4.4", 76 | "@angular/platform-browser-dynamic": "^4.4.4", 77 | "@commitlint/cli": "^4.1.1", 78 | "@commitlint/config-conventional": "^8.3.4", 79 | "@commitlint/prompt": "^8.3.5", 80 | "@compodoc/compodoc": "^1.1.11", 81 | "@types/chai": "^4.0.4", 82 | "@types/html-webpack-plugin": "^2.11.2", 83 | "@types/mocha": "^2.2.43", 84 | "@types/node": "^8.0.34", 85 | "@types/sinon": "^2.3.6", 86 | "@types/sinon-chai": "^2.7.29", 87 | "@types/webpack": "^3.0.13", 88 | "chai": "^4.1.2", 89 | "codecov": "^2.3.0", 90 | "codelyzer": "^3.2.1", 91 | "commitizen": "^2.8.1", 92 | "concurrently": "^3.0.0", 93 | "copyfiles": "^1.2.0", 94 | "core-js": "^2.5.1", 95 | "css-loader": "^0.28.7", 96 | "del-cli": "^1.0.0", 97 | "fork-ts-checker-webpack-plugin": "^0.2.8", 98 | "html-webpack-plugin": "^2.30.1", 99 | "husky": "^4.2.1", 100 | "istanbul-instrumenter-loader": "^3.0.0", 101 | "karma": "^1.7.1", 102 | "karma-chrome-launcher": "^2.1.1", 103 | "karma-coverage-istanbul-reporter": "^1.0.0", 104 | "karma-mocha": "^1.3.0", 105 | "karma-mocha-reporter": "^2.2.4", 106 | "karma-sourcemap-loader": "^0.3.7", 107 | "karma-webpack": "^2.0.5", 108 | "lint-staged": "^4.2.3", 109 | "mocha": "^4.0.1", 110 | "node-sass": "^4.13.1", 111 | "prettier": "^1.7.4", 112 | "rxjs": "^5.4.3", 113 | "sass-loader": "^6.0.6", 114 | "sinon": "^4.0.1", 115 | "sinon-chai": "^2.14.0", 116 | "standard-version": "^4.0.0", 117 | "style-loader": "^0.19.0", 118 | "ts-loader": "^2.3.7", 119 | "ts-node": "^3.3.0", 120 | "tslint": "^5.7.0", 121 | "tslint-config-mwl": "^0.2.0", 122 | "tslint-loader": "^3.5.3", 123 | "typescript": "^2.5.3", 124 | "webpack": "^3.6.0", 125 | "webpack-angular-externals": "^1.0.0", 126 | "webpack-dev-server": "^2.11.5", 127 | "webpack-rxjs-externals": "^1.0.0", 128 | "zone.js": "^0.8.18" 129 | }, 130 | "peerDependencies": { 131 | "@angular/core": ">=2.0.0" 132 | }, 133 | "husky": { 134 | "hooks": { 135 | "commit-msg": "commitlint -e", 136 | "pre-commit": "lint-staged" 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/highlight-tag.interface.ts: -------------------------------------------------------------------------------- 1 | export interface HighlightTag { 2 | indices: { 3 | start: number; 4 | end: number; 5 | }; 6 | cssClass?: string; 7 | data?: any; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './text-input-highlight.module'; 2 | export * from './highlight-tag.interface'; 3 | -------------------------------------------------------------------------------- /src/text-input-element.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: 'textarea[mwlTextInputElement]', 5 | host: { 6 | '[class.text-input-element]': 'true' 7 | } 8 | }) 9 | export class TextInputElementDirective {} 10 | -------------------------------------------------------------------------------- /src/text-input-highlight-container.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[mwlTextInputHighlightContainer]', 5 | host: { 6 | '[class.text-input-highlight-container]': 'true' 7 | } 8 | }) 9 | export class TextInputHighlightContainerDirective {} 10 | -------------------------------------------------------------------------------- /src/text-input-highlight.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | Component, 4 | ElementRef, 5 | EventEmitter, 6 | HostListener, 7 | Input, 8 | OnChanges, 9 | OnDestroy, 10 | Output, 11 | Renderer2, 12 | SimpleChanges, 13 | ViewChild 14 | } from '@angular/core'; 15 | import { HighlightTag } from './highlight-tag.interface'; 16 | 17 | const styleProperties = Object.freeze([ 18 | 'direction', // RTL support 19 | 'boxSizing', 20 | 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does 21 | 'height', 22 | 'overflowX', 23 | 'overflowY', // copy the scrollbar for IE 24 | 25 | 'borderTopWidth', 26 | 'borderRightWidth', 27 | 'borderBottomWidth', 28 | 'borderLeftWidth', 29 | 'borderStyle', 30 | 31 | 'paddingTop', 32 | 'paddingRight', 33 | 'paddingBottom', 34 | 'paddingLeft', 35 | 36 | // https://developer.mozilla.org/en-US/docs/Web/CSS/font 37 | 'fontStyle', 38 | 'fontVariant', 39 | 'fontWeight', 40 | 'fontStretch', 41 | 'fontSize', 42 | 'fontSizeAdjust', 43 | 'lineHeight', 44 | 'fontFamily', 45 | 46 | 'textAlign', 47 | 'textTransform', 48 | 'textIndent', 49 | 'textDecoration', // might not make a difference, but better be safe 50 | 51 | 'letterSpacing', 52 | 'wordSpacing', 53 | 54 | 'tabSize', 55 | 'MozTabSize' 56 | ]); 57 | 58 | const tagIndexIdPrefix = 'text-highlight-tag-id-'; 59 | 60 | function indexIsInsideTag(index: number, tag: HighlightTag) { 61 | return tag.indices.start < index && index < tag.indices.end; 62 | } 63 | 64 | function overlaps(tagA: HighlightTag, tagB: HighlightTag) { 65 | return ( 66 | indexIsInsideTag(tagB.indices.start, tagA) || 67 | indexIsInsideTag(tagB.indices.end, tagA) 68 | ); 69 | } 70 | 71 | function isCoordinateWithinRect(rect: ClientRect, x: number, y: number) { 72 | return rect.left < x && x < rect.right && (rect.top < y && y < rect.bottom); 73 | } 74 | 75 | function escapeHtml(str: string): string { 76 | return str.replace(//g, '>'); 77 | } 78 | 79 | export interface TagMouseEvent { 80 | tag: HighlightTag; 81 | target: HTMLElement; 82 | event: MouseEvent; 83 | } 84 | 85 | @Component({ 86 | selector: 'mwl-text-input-highlight', 87 | template: ` 88 |
93 |
94 | ` 95 | }) 96 | export class TextInputHighlightComponent implements OnChanges, OnDestroy { 97 | /** 98 | * The CSS class to add to highlighted tags 99 | */ 100 | @Input() tagCssClass: string = ''; 101 | 102 | /** 103 | * An array of indices of the textarea value to highlight 104 | */ 105 | @Input() tags: HighlightTag[] = []; 106 | 107 | /** 108 | * The textarea to highlight 109 | */ 110 | @Input() textInputElement: HTMLTextAreaElement; 111 | 112 | /** 113 | * The textarea value, in not provided will fall back to trying to grab it automatically from the textarea 114 | */ 115 | @Input() textInputValue: string; 116 | 117 | /** 118 | * Called when the area over a tag is clicked 119 | */ 120 | @Output() tagClick = new EventEmitter(); 121 | 122 | /** 123 | * Called when the area over a tag is moused over 124 | */ 125 | @Output() tagMouseEnter = new EventEmitter(); 126 | 127 | /** 128 | * Called when the area over the tag has the mouse is removed from it 129 | */ 130 | @Output() tagMouseLeave = new EventEmitter(); 131 | 132 | /** 133 | * @private 134 | */ 135 | highlightElementContainerStyle: { [key: string]: string } = {}; 136 | 137 | /** 138 | * @private 139 | */ 140 | highlightedText: string; 141 | 142 | @ViewChild('highlightElement') private highlightElement: ElementRef; 143 | 144 | private textareaEventListeners: Array<() => void> = []; 145 | 146 | private highlightTagElements: Array<{ 147 | element: HTMLElement; 148 | clientRect: ClientRect; 149 | }>; 150 | 151 | private mouseHoveredTag: TagMouseEvent | undefined; 152 | 153 | private isDestroyed = false; 154 | 155 | constructor(private renderer: Renderer2, private cdr: ChangeDetectorRef) {} 156 | 157 | /** 158 | * Manually call this function to refresh the highlight element if the textarea styles have changed 159 | */ 160 | refresh() { 161 | const computed: any = getComputedStyle(this.textInputElement); 162 | styleProperties.forEach(prop => { 163 | this.highlightElementContainerStyle[prop] = computed[prop]; 164 | }); 165 | } 166 | 167 | /** 168 | * @private 169 | */ 170 | ngOnChanges(changes: SimpleChanges): void { 171 | if (changes.textInputElement) { 172 | this.textInputElementChanged(); 173 | } 174 | 175 | if (changes.tags || changes.tagCssClass || changes.textInputValue) { 176 | this.addTags(); 177 | } 178 | } 179 | 180 | /** 181 | * @private 182 | */ 183 | ngOnDestroy(): void { 184 | this.isDestroyed = true; 185 | this.textareaEventListeners.forEach(unregister => unregister()); 186 | } 187 | 188 | /** 189 | * @private 190 | */ 191 | @HostListener('window:resize') 192 | onWindowResize() { 193 | this.refresh(); 194 | } 195 | 196 | private textInputElementChanged() { 197 | const elementType = this.textInputElement.tagName.toLowerCase(); 198 | if (elementType !== 'textarea') { 199 | throw new Error( 200 | 'The angular-text-input-highlight component must be passed ' + 201 | 'a textarea to the `textInputElement` input. Instead received a ' + 202 | elementType 203 | ); 204 | } 205 | 206 | setTimeout(() => { 207 | // in case the element was destroyed before the timeout fires 208 | if (!this.isDestroyed) { 209 | this.refresh(); 210 | 211 | this.textareaEventListeners.forEach(unregister => unregister()); 212 | this.textareaEventListeners = [ 213 | this.renderer.listen(this.textInputElement, 'input', () => { 214 | this.addTags(); 215 | }), 216 | this.renderer.listen(this.textInputElement, 'scroll', () => { 217 | this.highlightElement.nativeElement.scrollTop = this.textInputElement.scrollTop; 218 | this.highlightTagElements = this.highlightTagElements.map(tag => { 219 | tag.clientRect = tag.element.getBoundingClientRect(); 220 | return tag; 221 | }); 222 | }), 223 | this.renderer.listen(this.textInputElement, 'mouseup', () => { 224 | this.refresh(); 225 | }) 226 | ]; 227 | 228 | // only add event listeners if the host component actually asks for it 229 | if (this.tagClick.observers.length > 0) { 230 | const onClick = this.renderer.listen( 231 | this.textInputElement, 232 | 'click', 233 | event => { 234 | this.handleTextareaMouseEvent(event, 'click'); 235 | } 236 | ); 237 | this.textareaEventListeners.push(onClick); 238 | } 239 | 240 | if (this.tagMouseEnter.observers.length > 0) { 241 | const onMouseMove = this.renderer.listen( 242 | this.textInputElement, 243 | 'mousemove', 244 | event => { 245 | this.handleTextareaMouseEvent(event, 'mousemove'); 246 | } 247 | ); 248 | const onMouseLeave = this.renderer.listen( 249 | this.textInputElement, 250 | 'mouseleave', 251 | event => { 252 | if (this.mouseHoveredTag) { 253 | this.tagMouseLeave.emit(this.mouseHoveredTag); 254 | this.mouseHoveredTag = undefined; 255 | } 256 | } 257 | ); 258 | this.textareaEventListeners.push(onMouseMove); 259 | this.textareaEventListeners.push(onMouseLeave); 260 | } 261 | 262 | this.addTags(); 263 | } 264 | }); 265 | } 266 | 267 | private addTags() { 268 | const textInputValue = 269 | typeof this.textInputValue !== 'undefined' 270 | ? this.textInputValue 271 | : this.textInputElement.value; 272 | 273 | const prevTags: HighlightTag[] = []; 274 | const parts: string[] = []; 275 | 276 | [...this.tags] 277 | .sort((tagA, tagB) => { 278 | return tagA.indices.start - tagB.indices.start; 279 | }) 280 | .forEach(tag => { 281 | if (tag.indices.start > tag.indices.end) { 282 | throw new Error( 283 | `Highlight tag with indices [${tag.indices.start}, ${tag.indices 284 | .end}] cannot start after it ends.` 285 | ); 286 | } 287 | 288 | prevTags.forEach(prevTag => { 289 | if (overlaps(prevTag, tag)) { 290 | throw new Error( 291 | `Highlight tag with indices [${tag.indices.start}, ${tag.indices 292 | .end}] overlaps with tag [${prevTag.indices.start}, ${prevTag 293 | .indices.end}]` 294 | ); 295 | } 296 | }); 297 | 298 | // TODO - implement this as an ngFor of items that is generated in the template for a cleaner solution 299 | 300 | const expectedTagLength = tag.indices.end - tag.indices.start; 301 | const tagContents = textInputValue.slice( 302 | tag.indices.start, 303 | tag.indices.end 304 | ); 305 | if (tagContents.length === expectedTagLength) { 306 | const previousIndex = 307 | prevTags.length > 0 ? prevTags[prevTags.length - 1].indices.end : 0; 308 | const before = textInputValue.slice(previousIndex, tag.indices.start); 309 | parts.push(escapeHtml(before)); 310 | const cssClass = tag.cssClass || this.tagCssClass; 311 | const tagId = tagIndexIdPrefix + this.tags.indexOf(tag); 312 | // text-highlight-tag-id-${id} is used instead of a data attribute to prevent an angular sanitization warning 313 | parts.push( 314 | `${escapeHtml( 315 | tagContents 316 | )}` 317 | ); 318 | prevTags.push(tag); 319 | } 320 | }); 321 | const remainingIndex = 322 | prevTags.length > 0 ? prevTags[prevTags.length - 1].indices.end : 0; 323 | const remaining = textInputValue.slice(remainingIndex); 324 | parts.push(escapeHtml(remaining)); 325 | parts.push(' '); 326 | this.highlightedText = parts.join(''); 327 | this.cdr.detectChanges(); 328 | this.highlightTagElements = Array.from( 329 | this.highlightElement.nativeElement.getElementsByTagName('span') 330 | ).map((element: HTMLElement) => { 331 | return { element, clientRect: element.getBoundingClientRect() }; 332 | }); 333 | } 334 | 335 | private handleTextareaMouseEvent( 336 | event: MouseEvent, 337 | eventName: 'click' | 'mousemove' 338 | ) { 339 | const matchingTagIndex = this.highlightTagElements.findIndex(elm => 340 | isCoordinateWithinRect(elm.clientRect, event.clientX, event.clientY) 341 | ); 342 | if (matchingTagIndex > -1) { 343 | const target = this.highlightTagElements[matchingTagIndex].element; 344 | const tagClass = Array.from(target.classList).find(className => 345 | className.startsWith(tagIndexIdPrefix) 346 | ); 347 | if (tagClass) { 348 | const tagId = tagClass.replace(tagIndexIdPrefix, ''); 349 | const tag: HighlightTag = this.tags[+tagId]; 350 | const tagMouseEvent = { tag, target, event }; 351 | if (eventName === 'click') { 352 | this.tagClick.emit(tagMouseEvent); 353 | } else if (!this.mouseHoveredTag) { 354 | this.mouseHoveredTag = tagMouseEvent; 355 | this.tagMouseEnter.emit(tagMouseEvent); 356 | } 357 | } 358 | } else if (eventName === 'mousemove' && this.mouseHoveredTag) { 359 | this.mouseHoveredTag.event = event; 360 | this.tagMouseLeave.emit(this.mouseHoveredTag); 361 | this.mouseHoveredTag = undefined; 362 | } 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/text-input-highlight.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { TextInputHighlightComponent } from './text-input-highlight.component'; 4 | import { TextInputHighlightContainerDirective } from './text-input-highlight-container.directive'; 5 | import { TextInputElementDirective } from './text-input-element.directive'; 6 | 7 | @NgModule({ 8 | declarations: [ 9 | TextInputHighlightComponent, 10 | TextInputHighlightContainerDirective, 11 | TextInputElementDirective 12 | ], 13 | imports: [CommonModule], 14 | exports: [ 15 | TextInputHighlightComponent, 16 | TextInputHighlightContainerDirective, 17 | TextInputElementDirective 18 | ] 19 | }) 20 | export class TextInputHighlightModule {} 21 | -------------------------------------------------------------------------------- /src/text-input-highlight.scss: -------------------------------------------------------------------------------- 1 | .text-input-highlight-container { 2 | position: relative; 3 | 4 | .text-input-element { 5 | background: none; 6 | position: relative; 7 | z-index: 2; 8 | } 9 | 10 | .text-highlight-element { 11 | overflow: hidden !important; 12 | word-break: break-word; 13 | white-space: pre-wrap; 14 | position: absolute; 15 | top: 0; 16 | bottom: 0; 17 | left: 0; 18 | right: 0; 19 | background: white; 20 | color: rgba(0, 0, 0, 0); 21 | z-index: 1; 22 | } 23 | 24 | .text-highlight-tag { 25 | border-radius: 8px; 26 | padding: 1px 3px; 27 | margin: -1px -3px; 28 | overflow-wrap: break-word; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/entry.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import 'core-js'; 4 | import 'zone.js/dist/zone'; 5 | import 'zone.js/dist/long-stack-trace-zone'; 6 | import 'zone.js/dist/async-test'; 7 | import 'zone.js/dist/fake-async-test'; 8 | import 'zone.js/dist/sync-test'; 9 | import 'zone.js/dist/proxy'; 10 | import 'zone.js/dist/mocha-patch'; 11 | import 'rxjs/Rx'; 12 | import { use } from 'chai'; 13 | import * as sinonChai from 'sinon-chai'; 14 | import { TestBed } from '@angular/core/testing'; 15 | import { 16 | BrowserDynamicTestingModule, 17 | platformBrowserDynamicTesting 18 | } from '@angular/platform-browser-dynamic/testing'; 19 | 20 | use(sinonChai); 21 | 22 | TestBed.initTestEnvironment( 23 | BrowserDynamicTestingModule, 24 | platformBrowserDynamicTesting() 25 | ); 26 | 27 | declare const require: any; 28 | const testsContext: any = require.context('./', true, /\.spec/); 29 | testsContext.keys().forEach(testsContext); 30 | -------------------------------------------------------------------------------- /test/text-input-highlight.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentFixture, 3 | fakeAsync, 4 | flush, 5 | TestBed 6 | } from '@angular/core/testing'; 7 | import { expect } from 'chai'; 8 | import * as sinon from 'sinon'; 9 | import { TextInputHighlightModule } from '../src'; 10 | import { Component } from '@angular/core'; 11 | import { HighlightTag } from '../src/highlight-tag.interface'; 12 | import { By } from '@angular/platform-browser'; 13 | import { TextInputHighlightComponent } from '../src/text-input-highlight.component'; 14 | import { FormsModule } from '@angular/forms'; 15 | 16 | @Component({ 17 | template: ` 18 |
19 | 24 | 31 | 32 |
33 | `, 34 | styles: [ 35 | ` 36 | textarea { 37 | height: 50px; 38 | width: 100px; 39 | } 40 | ` 41 | ] 42 | }) 43 | class TestComponent { 44 | text: string; 45 | tags: HighlightTag[] = []; 46 | tagCssClass: string; 47 | tagClick = sinon.spy(); 48 | tagMouseEnter = sinon.spy(); 49 | tagMouseLeave = sinon.spy(); 50 | textInputValue: string; 51 | refresh = sinon.spy(); 52 | } 53 | 54 | function createComponent({ 55 | text, 56 | tags 57 | }: { 58 | text: string; 59 | tags: HighlightTag[]; 60 | }) { 61 | const fixture: ComponentFixture = TestBed.createComponent( 62 | TestComponent 63 | ); 64 | fixture.componentInstance.text = text; 65 | fixture.componentInstance.tags = tags; 66 | fixture.detectChanges(); 67 | const textarea = fixture.debugElement.query(By.css('textarea')); 68 | const highlight = fixture.debugElement.query( 69 | By.directive(TextInputHighlightComponent) 70 | ); 71 | return { fixture, textarea, highlight }; 72 | } 73 | 74 | function flushTagsChanges(fixture: ComponentFixture) { 75 | fixture.detectChanges(); 76 | flush(); 77 | fixture.detectChanges(); 78 | } 79 | 80 | describe('mwl-text-input-highlight component', () => { 81 | beforeEach(() => { 82 | TestBed.configureTestingModule({ 83 | imports: [FormsModule, TextInputHighlightModule], 84 | declarations: [TestComponent] 85 | }); 86 | }); 87 | 88 | it( 89 | 'should highlight all given tags', 90 | fakeAsync(() => { 91 | const { highlight, fixture } = createComponent({ 92 | text: 'this is some text', 93 | tags: [{ indices: { start: 8, end: 12 } }] 94 | }); 95 | flushTagsChanges(fixture); 96 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 97 | 'this is some text ' 98 | ); 99 | }) 100 | ); 101 | 102 | it( 103 | 'should update the highlight element as the user types', 104 | fakeAsync(() => { 105 | const { highlight, fixture, textarea } = createComponent({ 106 | text: 'this is some text', 107 | tags: [{ indices: { start: 8, end: 12 } }] 108 | }); 109 | flushTagsChanges(fixture); 110 | const errorStub = sinon.stub(console, 'error'); // silence an error I cant debug easily 111 | textarea.nativeElement.value = 112 | 'this is some text that the user has typed in'; 113 | textarea.triggerEventHandler('input', {}); 114 | flushTagsChanges(fixture); 115 | errorStub.restore(); 116 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 117 | 'this is some text that the user has typed in ' 118 | ); 119 | }) 120 | ); 121 | 122 | it( 123 | 'should allow a default highlight css tag to be set', 124 | fakeAsync(() => { 125 | TestBed.overrideComponent(TestComponent, { 126 | set: { 127 | template: ` 128 |
129 | 134 | 138 | 139 |
140 | ` 141 | } 142 | }); 143 | const { highlight, fixture } = createComponent({ 144 | text: 'this is some text', 145 | tags: [{ indices: { start: 8, end: 12 } }] 146 | }); 147 | fixture.componentInstance.tagCssClass = 'foo-class'; 148 | flushTagsChanges(fixture); 149 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 150 | 'this is some text ' 151 | ); 152 | }) 153 | ); 154 | 155 | it( 156 | 'should copy all styling across from the textarea to the highlight element', 157 | fakeAsync(() => { 158 | const { highlight, fixture } = createComponent({ 159 | text: 'this is some text', 160 | tags: [{ indices: { start: 8, end: 12 } }] 161 | }); 162 | flushTagsChanges(fixture); 163 | expect(highlight.nativeElement.children[0].style.height).to.equal('50px'); 164 | }) 165 | ); 166 | 167 | it('should throw when a non textarea element is passed to textInputElement', () => { 168 | TestBed.overrideComponent(TestComponent, { 169 | set: { 170 | template: ` 171 |
172 | 173 | 178 | 182 | 183 |
184 | ` 185 | } 186 | }); 187 | expect( 188 | fakeAsync(() => { 189 | createComponent({ 190 | text: 'this is some text', 191 | tags: [{ indices: { start: 8, end: 12 } }] 192 | }); 193 | }) 194 | ).to.throw(); 195 | }); 196 | 197 | it( 198 | 'tag ordering should not affect highlighting', 199 | fakeAsync(() => { 200 | const { highlight, fixture } = createComponent({ 201 | text: 'this is some text', 202 | tags: [ 203 | { indices: { start: 8, end: 12 } }, 204 | { indices: { start: 0, end: 4 } } 205 | ] 206 | }); 207 | flushTagsChanges(fixture); 208 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 209 | 'this is some text ' 210 | ); 211 | }) 212 | ); 213 | 214 | it( 215 | 'should allow an individual tag css class to be overridden', 216 | fakeAsync(() => { 217 | const { highlight, fixture } = createComponent({ 218 | text: 'this is some text', 219 | tags: [ 220 | { indices: { start: 8, end: 12 } }, 221 | { indices: { start: 0, end: 4 }, cssClass: 'foo-class' } 222 | ] 223 | }); 224 | flushTagsChanges(fixture); 225 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 226 | 'this is some text ' 227 | ); 228 | }) 229 | ); 230 | 231 | it('should throw when the start index of a tag is more than the end index', () => { 232 | expect( 233 | fakeAsync(() => { 234 | const { fixture } = createComponent({ 235 | text: 'this is some text', 236 | tags: [{ indices: { start: 12, end: 8 } }] 237 | }); 238 | flushTagsChanges(fixture); 239 | }) 240 | ).to.throw(); 241 | }); 242 | 243 | it( 244 | 'should skip tags where the tag indices dont exist on the textarea value', 245 | fakeAsync(() => { 246 | const { highlight, fixture } = createComponent({ 247 | text: 'this is some text', 248 | tags: [{ indices: { start: 8, end: 100 } }] 249 | }); 250 | flushTagsChanges(fixture); 251 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 252 | 'this is some text ' 253 | ); 254 | }) 255 | ); 256 | 257 | it('should throw when tag indices overlap', () => { 258 | expect( 259 | fakeAsync(() => { 260 | const { fixture } = createComponent({ 261 | text: 'this is some text', 262 | tags: [ 263 | { indices: { start: 8, end: 12 } }, 264 | { indices: { start: 6, end: 10 } } 265 | ] 266 | }); 267 | flushTagsChanges(fixture); 268 | }) 269 | ).to.throw(); 270 | }); 271 | 272 | it( 273 | 'should fire the mouse clicked event', 274 | fakeAsync(() => { 275 | const { highlight, fixture } = createComponent({ 276 | text: 'this is some text', 277 | tags: [{ indices: { start: 8, end: 12 } }] 278 | }); 279 | flushTagsChanges(fixture); 280 | const spanRect = fixture.nativeElement 281 | .querySelector('.text-highlight-tag') 282 | .getBoundingClientRect(); 283 | const eventObj: any = { 284 | clientX: spanRect.left + 1, 285 | clientY: spanRect.top + 1 286 | }; 287 | fixture.debugElement 288 | .query(By.css('textarea')) 289 | .triggerEventHandler('click', eventObj); 290 | fixture.detectChanges(); 291 | expect(fixture.componentInstance.tagClick).to.have.been.calledWith({ 292 | target: highlight.nativeElement.querySelector('span'), 293 | tag: { indices: { start: 8, end: 12 } }, 294 | event: eventObj 295 | }); 296 | }) 297 | ); 298 | 299 | it( 300 | 'should not fire the mouse clicked event', 301 | fakeAsync(() => { 302 | const { highlight, fixture } = createComponent({ 303 | text: 'this is some text', 304 | tags: [{ indices: { start: 8, end: 12 } }] 305 | }); 306 | flushTagsChanges(fixture); 307 | const spanRect = fixture.nativeElement 308 | .querySelector('.text-highlight-tag') 309 | .getBoundingClientRect(); 310 | fixture.debugElement 311 | .query(By.css('textarea')) 312 | .triggerEventHandler('click', { 313 | clientX: spanRect.left - 1, 314 | clientY: spanRect.top + 1 315 | }); 316 | fixture.detectChanges(); 317 | expect(fixture.componentInstance.tagClick.callCount).to.equal(0); 318 | }) 319 | ); 320 | 321 | it( 322 | 'should fire the mouse enter and leave events', 323 | fakeAsync(() => { 324 | const { highlight, fixture } = createComponent({ 325 | text: 'this is some text', 326 | tags: [{ indices: { start: 8, end: 12 } }] 327 | }); 328 | flushTagsChanges(fixture); 329 | const spanRect = fixture.nativeElement 330 | .querySelector('.text-highlight-tag') 331 | .getBoundingClientRect(); 332 | const eventObj = { 333 | clientX: spanRect.left + 1, 334 | clientY: spanRect.top + 1 335 | }; 336 | fixture.debugElement 337 | .query(By.css('textarea')) 338 | .triggerEventHandler('mousemove', eventObj); 339 | fixture.detectChanges(); 340 | expect(fixture.componentInstance.tagMouseEnter).to.have.been.calledWith({ 341 | target: highlight.nativeElement.querySelector('span'), 342 | tag: { indices: { start: 8, end: 12 } }, 343 | event: eventObj 344 | }); 345 | expect(fixture.componentInstance.tagMouseLeave.callCount).to.equal(0); 346 | fixture.debugElement 347 | .query(By.css('textarea')) 348 | .triggerEventHandler('mousemove', { 349 | clientX: spanRect.left + 1, 350 | clientY: spanRect.top + 2 351 | }); 352 | fixture.detectChanges(); 353 | expect(fixture.componentInstance.tagMouseEnter.callCount).to.equal(1); 354 | 355 | const leaveEventObj = { 356 | clientX: spanRect.left - 1, 357 | clientY: spanRect.top - 1 358 | }; 359 | fixture.debugElement 360 | .query(By.css('textarea')) 361 | .triggerEventHandler('mousemove', leaveEventObj); 362 | fixture.detectChanges(); 363 | expect(fixture.componentInstance.tagMouseLeave).to.have.been.calledWith({ 364 | target: highlight.nativeElement.querySelector('span'), 365 | tag: { indices: { start: 8, end: 12 } }, 366 | event: leaveEventObj 367 | }); 368 | }) 369 | ); 370 | 371 | it( 372 | 'should fire the mouse leave event when the textarea is not moused over anymore', 373 | fakeAsync(() => { 374 | const { highlight, fixture } = createComponent({ 375 | text: 'this is some text', 376 | tags: [{ indices: { start: 8, end: 12 } }] 377 | }); 378 | flushTagsChanges(fixture); 379 | const spanRect = fixture.nativeElement 380 | .querySelector('.text-highlight-tag') 381 | .getBoundingClientRect(); 382 | const eventObj = { 383 | clientX: spanRect.left + 1, 384 | clientY: spanRect.top + 1 385 | }; 386 | fixture.debugElement 387 | .query(By.css('textarea')) 388 | .triggerEventHandler('mousemove', eventObj); 389 | fixture.detectChanges(); 390 | expect(fixture.componentInstance.tagMouseEnter).to.have.been.calledWith({ 391 | target: highlight.nativeElement.querySelector('span'), 392 | tag: { indices: { start: 8, end: 12 } }, 393 | event: eventObj 394 | }); 395 | expect(fixture.componentInstance.tagMouseLeave.callCount).to.equal(0); 396 | const leaveEventObj = { 397 | clientX: spanRect.left + 1, 398 | clientY: spanRect.top + 1 399 | }; 400 | fixture.debugElement 401 | .query(By.css('textarea')) 402 | .triggerEventHandler('mouseleave', leaveEventObj); 403 | fixture.detectChanges(); 404 | expect(fixture.componentInstance.tagMouseLeave).to.have.been.calledWith({ 405 | target: highlight.nativeElement.querySelector('span'), 406 | tag: { indices: { start: 8, end: 12 } }, 407 | event: leaveEventObj 408 | }); 409 | }) 410 | ); 411 | 412 | it('should refresh when textarea is resized', fakeAsync(() => { 413 | const { textarea, fixture } = createComponent({ 414 | text: 'this is some text', 415 | tags: [{ indices: { start: 8, end: 12 } }] 416 | }); 417 | flushTagsChanges(fixture); 418 | textarea.triggerEventHandler('mouseup', {}) 419 | fixture.detectChanges(); 420 | expect(fixture.componentInstance.refresh.calledOnce); 421 | })); 422 | 423 | it( 424 | 'should not break with html characters', 425 | fakeAsync(() => { 426 | const { highlight, fixture } = createComponent({ 427 | text: 'this some text ' 433 | ); 434 | }) 435 | ); 436 | 437 | it( 438 | 'should update the textareas dimensions when the window is resized', 439 | fakeAsync(() => { 440 | const { highlight, fixture, textarea } = createComponent({ 441 | text: 'this is some text', 442 | tags: [ 443 | { indices: { start: 8, end: 12 } }, 444 | { indices: { start: 0, end: 4 } } 445 | ] 446 | }); 447 | flushTagsChanges(fixture); 448 | const highlightElement = highlight.query( 449 | By.css('.text-highlight-element') 450 | ).nativeElement; 451 | expect(highlightElement.offsetWidth).to.equal(106); 452 | textarea.nativeElement.style.width = '200px'; 453 | highlight.componentInstance.onWindowResize(); 454 | fixture.detectChanges(); 455 | expect(highlightElement.offsetWidth).to.equal(206); 456 | }) 457 | ); 458 | 459 | it( 460 | 'should not throw when the fixture is destroyed before the first render completes', 461 | fakeAsync(() => { 462 | const { fixture } = createComponent({ 463 | text: 'this is some text', 464 | tags: [{ indices: { start: 8, end: 12 } }] 465 | }); 466 | fixture.destroy(); 467 | expect(() => flush()).not.to.throw(); 468 | }) 469 | ); 470 | 471 | it('should allow the textarea value to be overridden', () => { 472 | const { highlight, fixture } = createComponent({ 473 | text: 'this is some text', 474 | tags: [{ indices: { start: 8, end: 12 } }] 475 | }); 476 | fixture.componentInstance.textInputValue = 'this is some text'; 477 | fixture.detectChanges(); 478 | expect(highlight.nativeElement.children[0].innerHTML).to.deep.equal( 479 | 'this is some text ' 480 | ); 481 | }); 482 | }); 483 | -------------------------------------------------------------------------------- /tsconfig-compodoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "experimentalDecorators": true, 7 | "lib": ["es2015", "dom"] 8 | }, 9 | "include": [ 10 | "src" 11 | ], 12 | "exclude": [ 13 | "node_modules", 14 | "demo", 15 | "test", 16 | "karma.conf.ts", 17 | "webpack.config.ts", 18 | "webpack.config.umd.ts" 19 | ] 20 | } -------------------------------------------------------------------------------- /tsconfig-ngc.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "experimentalDecorators": true, 8 | "baseUrl": "", 9 | "stripInternal": true, 10 | "outDir": "./dist", 11 | "sourceMap": true, 12 | "inlineSources": true, 13 | "types": [], 14 | "lib": ["es2015", "dom"], 15 | "importHelpers": true 16 | }, 17 | "files": [ 18 | "src/index.ts" 19 | ], 20 | "angularCompilerOptions": { 21 | "strictMetadataEmit": true, 22 | "genDir": "./dist/", 23 | "skipTemplateCodegen": true, 24 | "trace": true 25 | } 26 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "lib": ["es2015", "dom"], 10 | "strict": true, 11 | "skipLibCheck": true 12 | }, 13 | "include": [ 14 | "src/**/*", 15 | "test/**/*", 16 | "demo/**/*", 17 | "custom-typings.d.ts" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-mwl", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "mwl", "camelCase"], 5 | "component-selector": [true, "element", "mwl", "kebab-case"], 6 | "pipe-naming": [true, "camelCase", "mwl"], 7 | "no-inferrable-types": false, 8 | "use-host-property-decorator": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import * as path from 'path'; 3 | import * as HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import * as ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 5 | 6 | const IS_PROD: boolean = process.argv.indexOf('-p') > -1; 7 | 8 | export default { 9 | devtool: IS_PROD ? 'source-map' : 'eval', 10 | entry: path.join(__dirname, 'demo', 'entry.ts'), 11 | output: { 12 | filename: IS_PROD ? '[name]-[chunkhash].js' : '[name].js' 13 | }, 14 | module: { 15 | rules: [{ 16 | test: /\.ts$/, 17 | loader: 'tslint-loader', 18 | exclude: /node_modules/, 19 | enforce: 'pre' 20 | }, { 21 | test: /\.ts$/, 22 | loader: 'ts-loader', 23 | exclude: /node_modules/, 24 | options: { 25 | transpileOnly: !IS_PROD 26 | } 27 | }, { 28 | test: /\.scss$/, 29 | use: [ 30 | 'style-loader', 31 | 'css-loader', 32 | 'sass-loader' 33 | ], 34 | exclude: /node_modules/ 35 | }] 36 | }, 37 | resolve: { 38 | extensions: ['.ts', '.js'] 39 | }, 40 | devServer: { 41 | port: 8000, 42 | inline: true, 43 | hot: true, 44 | historyApiFallback: true 45 | }, 46 | plugins: [ 47 | ...(IS_PROD ? [] : [ 48 | new webpack.HotModuleReplacementPlugin(), 49 | new ForkTsCheckerWebpackPlugin({ 50 | watch: ['./src', './demo'] 51 | }) 52 | ]), 53 | new webpack.DefinePlugin({ 54 | ENV: JSON.stringify(IS_PROD ? 'production' : 'development') 55 | }), 56 | new webpack.ContextReplacementPlugin( 57 | /angular(\\|\/)core(\\|\/)@angular/, 58 | path.join(__dirname, 'src') 59 | ), 60 | new HtmlWebpackPlugin({ 61 | template: path.join(__dirname, 'demo', 'index.ejs') 62 | }) 63 | ] 64 | }; 65 | -------------------------------------------------------------------------------- /webpack.config.umd.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as webpack from 'webpack'; 4 | import * as angularExternals from 'webpack-angular-externals'; 5 | import * as rxjsExternals from 'webpack-rxjs-externals'; 6 | 7 | const pkg = JSON.parse(fs.readFileSync('./package.json').toString()); 8 | 9 | export default { 10 | entry: { 11 | 'angular-text-input-highlight.umd': path.join(__dirname, 'src', 'index.ts'), 12 | 'angular-text-input-highlight.umd.min': path.join(__dirname, 'src', 'index.ts'), 13 | }, 14 | output: { 15 | path: path.join(__dirname, 'dist', 'bundles'), 16 | filename: '[name].js', 17 | libraryTarget: 'umd', 18 | library: 'angularTextInputHighlight' 19 | }, 20 | externals: [ 21 | angularExternals(), 22 | rxjsExternals() 23 | ], 24 | devtool: 'source-map', 25 | module: { 26 | rules: [{ 27 | test: /\.ts$/, 28 | loader: 'tslint-loader', 29 | exclude: /node_modules/, 30 | enforce: 'pre', 31 | options: { 32 | emitErrors: true, 33 | failOnHint: true 34 | } 35 | }, { 36 | test: /\.ts$/, 37 | loader: 'ts-loader', 38 | exclude: /node_modules/ 39 | }] 40 | }, 41 | resolve: { 42 | extensions: ['.ts', '.js'] 43 | }, 44 | plugins: [ 45 | new webpack.optimize.UglifyJsPlugin({ 46 | include: /\.min\.js$/, 47 | sourceMap: true 48 | }), 49 | new webpack.ContextReplacementPlugin( 50 | /angular(\\|\/)core(\\|\/)(esm(\\|\/)src|src)(\\|\/)linker/, 51 | path.join(__dirname, 'src') 52 | ), 53 | new webpack.BannerPlugin({ 54 | banner: ` 55 | /** 56 | * ${pkg.name} - ${pkg.description} 57 | * @version v${pkg.version} 58 | * @author ${pkg.author} 59 | * @link ${pkg.homepage} 60 | * @license ${pkg.license} 61 | */ 62 | `.trim(), 63 | raw: true, 64 | entryOnly: true 65 | }) 66 | ] 67 | }; 68 | --------------------------------------------------------------------------------