├── .actrc ├── .github ├── act-event.json ├── dependabot.yml └── workflows │ ├── yarn-test.yml │ └── codeql-analysis.yml ├── demo ├── src │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── setupTests.tsx │ ├── Code.tsx │ ├── CodeSandbox.tsx │ ├── Array.tsx │ ├── App.css │ ├── String.tsx │ ├── Range.tsx │ ├── Regexp.tsx │ ├── index.tsx │ ├── App.tsx │ ├── CustomObject.tsx │ ├── NavBar.tsx │ ├── Performance.tsx │ ├── ChangeValue.tsx │ ├── Example.tsx │ ├── DraftPerf.tsx │ ├── StrategyDemo.tsx │ ├── logo.svg │ ├── QuillPerf.tsx │ ├── Component.tsx │ ├── ChangeSelection.tsx │ └── Unwrapped.tsx ├── .env ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon-128x128.png │ ├── index.css │ ├── index.html │ └── manifest.json ├── tsconfig.json └── package.json ├── .gitignore ├── .travis.yml ├── src ├── index.ts ├── createDecorator.ts ├── HighlightWithinTextareaCC.tsx ├── getType.tsx ├── types.d.ts ├── getMatches.ts ├── breakSpansByBlocks.ts ├── highlightToFlatStrategy.ts ├── DecoratorFactory.tsx ├── Selection.tsx └── HighlightWithinTextarea.tsx ├── .vscode ├── tasks.json └── launch.json ├── tsconfig.json ├── CONTRIBUTING.md ├── package.json ├── HISTORY.md ├── DEVELOP.md ├── README.md └── yarn.lock /.actrc: -------------------------------------------------------------------------------- 1 | -e .github/act-event.json 2 | -------------------------------------------------------------------------------- /.github/act-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "act": true 3 | } 4 | -------------------------------------------------------------------------------- /demo/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/.env: -------------------------------------------------------------------------------- 1 | PUBLIC_URL=https://bonafideduck.github.io/react-highlight-within-textarea/ 2 | -------------------------------------------------------------------------------- /demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonafideduck/react-highlight-within-textarea/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonafideduck/react-highlight-within-textarea/HEAD/demo/public/favicon-16x16.png -------------------------------------------------------------------------------- /demo/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonafideduck/react-highlight-within-textarea/HEAD/demo/public/favicon-32x32.png -------------------------------------------------------------------------------- /demo/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonafideduck/react-highlight-within-textarea/HEAD/demo/public/favicon-96x96.png -------------------------------------------------------------------------------- /demo/public/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonafideduck/react-highlight-within-textarea/HEAD/demo/public/favicon-128x128.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /es 3 | /lib 4 | /node_modules 5 | /demo/node_modules 6 | /demo/dist 7 | /demo/build 8 | demo/yarn.lock 9 | /umd 10 | npm-debug.log* 11 | *.swp 12 | *.swo 13 | .DS_Store 14 | yarn-error.log 15 | -------------------------------------------------------------------------------- /demo/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /demo/src/setupTests.tsx: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /demo/src/Code.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SyntaxHighlighter from "react-syntax-highlighter"; 3 | 4 | const Code = (props: { code: string | string[] }) => { 5 | return ( 6 | 7 | {props.code} 8 | 9 | ); 10 | }; 11 | 12 | export { Code }; 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 10 6 | 7 | before_install: 8 | - npm install codecov.io coveralls 9 | 10 | after_success: 11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 13 | 14 | branches: 15 | only: 16 | - master 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import HighlightWithinTextarea from "./HighlightWithinTextarea"; 2 | 3 | export { HighlightWithinTextarea }; 4 | export { HighlightWithinTextareaCC } from "./HighlightWithinTextareaCC"; 5 | export { Selection } from "./Selection"; 6 | export { createDecorator } from "./createDecorator"; 7 | export { DecoratorFactory } from "./DecoratorFactory"; 8 | export { Highlight, Strategy, Callback } from './types' 9 | export default HighlightWithinTextarea; 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | target-branch: "main" 13 | -------------------------------------------------------------------------------- /src/createDecorator.ts: -------------------------------------------------------------------------------- 1 | import { ContentState } from "draft-js"; 2 | import { 3 | Highlight, 4 | } from "./types"; 5 | import { DecoratorFactory } from "./DecoratorFactory"; 6 | 7 | const createDecorator = ( 8 | contentState: ContentState, 9 | highlight: Highlight, 10 | text?: string 11 | ) => { 12 | console.warn('createDecorator is deprecated. Use DecoratorFactory().create'); 13 | text = text || contentState.getPlainText(); 14 | const decoratorFactory = new DecoratorFactory(); 15 | return decoratorFactory.create(contentState, highlight, text); 16 | }; 17 | 18 | export { createDecorator }; 19 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "npm: build", 10 | "detail": "yarn build:esm && yarn build:cjs" 11 | }, 12 | { 13 | "type": "npm", 14 | "script": "start", 15 | "problemMatcher": [], 16 | "label": "npm: start", 17 | "detail": "concurrently --kill-others 'tsc --watch' 'cd demo && npm start'", 18 | "group": { 19 | "kind": "build", 20 | "isDefault": true 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib/esm", 4 | "module": "esnext", 5 | "target": "ES2017", 6 | "lib": ["es6", "dom", "es2016", "es2017", "ES2021"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "moduleResolution": "node", 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "esModuleInterop": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "allowSyntheticDefaultImports": true 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "lib"] 21 | } 22 | -------------------------------------------------------------------------------- /demo/src/CodeSandbox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | interface Props { 3 | codeSandbox: string; 4 | style: React.CSSProperties; 5 | } 6 | 7 | function CodeSandbox(props: Props) { 8 | let { codeSandbox, style } = props; 9 | 10 | if (!codeSandbox) { 11 | return <>; 12 | } 13 | return ( 14 | 15 | Edit rhwta-string 20 | 21 | ); 22 | }; 23 | 24 | export { CodeSandbox }; 25 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /demo/public/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | #root mark { 16 | background-color: #d0bfff; 17 | } 18 | 19 | #root mark.blue { 20 | background-color: #a3daff; 21 | } 22 | 23 | #root mark.red { 24 | background-color: #ffc9c9; 25 | } 26 | 27 | #root mark.yellow { 28 | background-color: #ffec99; 29 | } 30 | -------------------------------------------------------------------------------- /demo/src/Array.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | 4 | const code = ``; 12 | 13 | const Array = () => { 14 | return ( 15 | <> 16 | 24 | 25 | ); 26 | }; 27 | 28 | export { Array }; 29 | -------------------------------------------------------------------------------- /src/HighlightWithinTextareaCC.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * For some reason, exporting a FunctionComponent 3 | * doesn't work when importing in codepen.io, so wrap 4 | * it in a class component. 5 | */ 6 | import * as React from 'react'; 7 | 8 | import HighlightWithinTextarea from './HighlightWithinTextarea'; 9 | import { HWTAProps } from './HighlightWithinTextarea'; 10 | import { Editor } from "draft-js"; 11 | 12 | interface CCProps extends HWTAProps { 13 | forwardRef?: Editor 14 | } 15 | export class HighlightWithinTextareaCC extends React.Component { 16 | constructor(props: CCProps) { 17 | super(props); 18 | } 19 | 20 | render() { 21 | return ; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 15 | RHWTA 16 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /demo/src/String.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | 4 | const code = `function String() { 5 | const [value, setValue] = useState("Potato potato tomato potato."); 6 | return ( 7 | setValue(value)} 11 | /> 12 | ); 13 | }`; 14 | 15 | const String = () => { 16 | return ( 17 | <> 18 | Note that this is case-insensitive.} 21 | initialValue="Potato potato tomato potato." 22 | highlight="potato" 23 | code={code} 24 | codeSandbox="rhwta-string-cg4y9" 25 | /> 26 | 27 | ); 28 | }; 29 | 30 | export { String }; 31 | -------------------------------------------------------------------------------- /demo/src/Range.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | 4 | const code = ``; 8 | 9 | const Range = () => { 10 | return ( 11 | <> 12 | 20 | 21 | ); 22 | }; 23 | 24 | export { Range }; 25 | -------------------------------------------------------------------------------- /demo/src/Regexp.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | 4 | const code = ``; 8 | 9 | const Regexp = () => { 10 | const initialValue = "Dog, cat, chicken, goose. Dogs, cats, chickens, geese. Multiline land\nshark" 11 | return ( 12 | 16 | Don't forget the g (find all) and i{" "} 17 | (case-insensitive) flags if you need them. 18 | 19 | } 20 | initialValue={initialValue} 21 | highlight={/dogs?|cats?|g(oo|ee)se|land\s+sharks?/gi} 22 | code={code} 23 | codeSandbox="rhwta-regexp-5ois8" 24 | /> 25 | ); 26 | }; 27 | 28 | export { Regexp }; 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) >= 10 must be installed. 4 | 5 | ## Installation 6 | 7 | - Running `npm install` in the component's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | - `npm test` will run the tests once. 16 | 17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 18 | 19 | - `npm run test:watch` will run the tests on every change. 20 | 21 | ## Building 22 | 23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app. 24 | 25 | - `npm run clean` will delete built resources. 26 | -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "RHWTA", 3 | "name": "React Highlight Within Textarea", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "128x128 96x96 32x32 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "favicon-128x128.png", 12 | "type": "image/png", 13 | "sizes": "128x128" 14 | }, 15 | { 16 | "src": "favicon-96x96.png", 17 | "type": "image/png", 18 | "sizes": "96x96" 19 | }, 20 | { 21 | "src": "favicon-32x32.png", 22 | "type": "image/png", 23 | "sizes": "32x32" 24 | }, 25 | { 26 | "src": "favicon-16x16.png", 27 | "type": "image/png", 28 | "sizes": "16x16" 29 | } 30 | ], 31 | "start_url": ".", 32 | "display": "standalone", 33 | "theme_color": "#000000", 34 | "background_color": "#ffffff" 35 | } 36 | -------------------------------------------------------------------------------- /src/getType.tsx: -------------------------------------------------------------------------------- 1 | import { Highlight } from './types' 2 | 3 | 4 | // returns identifier strings that aren't necessarily "real" JavaScript types 5 | export default function getType(instance?: Highlight) { 6 | let type = typeof instance; 7 | if (!instance) { 8 | return "falsey"; 9 | } else if (Array.isArray(instance)) { 10 | if ( 11 | instance.length === 2 && 12 | typeof instance[0] === "number" && 13 | typeof instance[1] === "number" 14 | ) { 15 | return "range"; 16 | } else { 17 | return "array"; 18 | } 19 | } else if (type === "object") { 20 | if (instance instanceof RegExp) { 21 | return "regexp"; 22 | } else if (instance.hasOwnProperty("highlight")) { 23 | return "custom"; 24 | } 25 | } else if (type === "function") { 26 | return "strategy" 27 | } else if (type === "string") { 28 | return type; 29 | } 30 | 31 | return "other"; 32 | } 33 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "compounds": [ 7 | { 8 | "name": "start & debug", 9 | "configurations": ["yarn start", "attach"] 10 | } 11 | ], 12 | "configurations": [ 13 | { 14 | "command": "yarn start", 15 | "name": "yarn start", 16 | "request": "launch", 17 | "type": "node-terminal" 18 | }, 19 | { 20 | "command": "yarn test", 21 | "name": "yarn test", 22 | "request": "launch", 23 | "type": "node-terminal" 24 | }, 25 | { 26 | "type": "chrome", 27 | "name": "attach", 28 | "request": "launch", 29 | "cwd": "${workspaceFolder}/demo", 30 | "url": "http://localhost:3000/" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { createHashRouter, RouterProvider } from "react-router-dom"; 4 | import App from "./App"; 5 | import { Performance } from "./Performance"; 6 | import { DraftPerf } from "./DraftPerf"; 7 | import { QuillPerf } from "./QuillPerf"; 8 | import "./App.css" 9 | 10 | const root = ReactDOM.createRoot( 11 | document.getElementById("root") as HTMLElement 12 | ); 13 | 14 | const router = createHashRouter([ 15 | { 16 | path: "/", 17 | element: , 18 | }, 19 | { 20 | path: "/performance", 21 | element: , 22 | }, 23 | { 24 | path: "/draftperf", 25 | element: , 26 | }, 27 | { 28 | path: "/quillperf", 29 | element: , 30 | }, 31 | ]); 32 | 33 | root.render( 34 | 35 | 36 | 37 | ); 38 | -------------------------------------------------------------------------------- /.github/workflows/yarn-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node 2 | 3 | name: Yarn Test 4 | 5 | on: 6 | push: 7 | branches: [ main, develop ] 8 | pull_request: 9 | # The branches below must be a subset of the branches above 10 | branches: [ main, develop ] 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | check-latest: true 20 | cache: 'yarn' 21 | cache-dependency-path: | 22 | yarn.lock 23 | demo/yarn.lock 24 | - name: yarn install 25 | run: yarn install --frozen-lockfile 26 | - name: yarn-install-demo 27 | working-directory: demo 28 | run: yarn install --frozen-lockfile 29 | - name: yarn build 30 | run: yarn run build 31 | - name: yarn-test-demo 32 | run: yarn test:ci 33 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | 3 | export type Callback = (start: number, end: number) => void; 4 | export type Strategy = (text: any, callback: Callback) => void; 5 | export type Range = [start: number, end: number]; 6 | export type Component = FC; 7 | export type CustomObject = { 8 | highlight: Highlight; 9 | component?: Component; 10 | className?: string; 11 | }; 12 | export type Highlight = 13 | | string 14 | | RegExp 15 | | Strategy 16 | | CustomObject 17 | | Range 18 | | Highlight[]; 19 | 20 | export interface BlockSpan { 21 | text: string; 22 | blockStart: number; 23 | blockEnd: number; 24 | blockText: string; 25 | blockKey: string; 26 | spanStart: number; 27 | spanEnd: number; 28 | spanText: string; 29 | component?: Component; 30 | className?: string; 31 | matchStart: number; 32 | matchEnd: number; 33 | matchText: string; 34 | } 35 | 36 | export type FlatStrategy = { 37 | strategy: Strategy; 38 | component?: Component; 39 | className?: string; 40 | }; 41 | 42 | interface Find { 43 | component?: Component; 44 | className?: string; 45 | matchStart: number; 46 | matchEnd: number; 47 | matchText: string; 48 | } 49 | -------------------------------------------------------------------------------- /src/getMatches.ts: -------------------------------------------------------------------------------- 1 | import { Find, FlatStrategy, Strategy } from "./types"; 2 | 3 | export const getMatches = (text: string, flatStrategies: FlatStrategy[]) => { 4 | // Calls each strategy to get all matches and then filters out overlaps. 5 | let finds: Find[] = []; 6 | for (const fs of flatStrategies) { 7 | const strategy = fs.strategy as Strategy; 8 | strategy(text, (start: number, end: number) => { 9 | if (start < end && start >= 0 && end <= text.length) { 10 | finds.push({ 11 | component: fs.component, 12 | className: fs.className, 13 | matchStart: start, 14 | matchEnd: end, 15 | matchText: text.slice(start, end), 16 | }); 17 | } 18 | }); 19 | } 20 | 21 | let maps = []; 22 | 23 | // Eliminate overlapping finds. 24 | loop: for (const find of finds) { 25 | for (let i = find.matchStart; i < find.matchEnd; i++) { 26 | if (maps[i]) { 27 | continue loop; 28 | } 29 | } 30 | for (let i = find.matchStart; i < find.matchEnd; i++) { 31 | maps[i] = find; 32 | } 33 | } 34 | 35 | let matches = Array.from(new Set(Object.values(maps))).sort( 36 | (a, b) => a.matchStart - b.matchStart 37 | ); 38 | return matches; 39 | }; 40 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Container from "react-bootstrap/Container"; 3 | import { NavBar } from "./NavBar"; 4 | import { String } from "./String"; 5 | import { Regexp } from "./Regexp"; 6 | import { Range } from "./Range"; 7 | import { Array } from "./Array"; 8 | import { StrategyDemo } from "./StrategyDemo"; 9 | import { CustomObject } from "./CustomObject"; 10 | import { Component } from "./Component"; 11 | import { ChangeValue } from "./ChangeValue"; 12 | import { ChangeSelection } from "./ChangeSelection"; 13 | import { Unwrapped } from "./Unwrapped"; 14 | 15 | const App = () => { 16 | return ( 17 |
18 | 27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-highlight-within-textarea", 3 | "version": "3.2.2", 4 | "description": "React component for highlighting text within a textarea", 5 | "homepage": "https://bonafideduck.github.io/react-highlight-within-textarea/", 6 | "repository": "git@github.com:bonafideduck/react-highlight-within-textarea.git", 7 | "author": "Mark Eklund ", 8 | "license": "MIT", 9 | "private": false, 10 | "main": "./lib/cjs/index.js", 11 | "module": "./lib/esm/index.js", 12 | "types": "./lib/esm/index.d.ts", 13 | "scripts": { 14 | "start": "concurrently --kill-others 'tsc --watch' 'cd demo && npm start'", 15 | "build": "yarn build:esm && yarn build:cjs", 16 | "build:esm": "tsc", 17 | "build:cjs": "tsc --module commonjs --outDir lib/cjs", 18 | "publish-pages": "cd demo && yarn publish-pages", 19 | "create-icons": "npx icongenie generate -p demo/icongenie.json", 20 | "test:ci": "cd demo && CI=true yarn test --all" 21 | }, 22 | "peerDependencies": { 23 | "draft-js": ">=0.11.7", 24 | "react": ">=0.14.0", 25 | "react-dom": ">=0.14.0" 26 | }, 27 | "devDependencies": { 28 | "@types/draft-js": ">=0.11.18", 29 | "concurrently": "^9.0.1", 30 | "draft-js": ">=0.11.7", 31 | "react": ">=18.3.1", 32 | "react-dom": ">=18.3.1", 33 | "typescript": "^5.6.2" 34 | }, 35 | "files": [ 36 | "/lib" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /demo/src/CustomObject.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | 4 | const text = ( 5 | 6 | Any type mentioned here can be put in an object wrapper with{" "} 7 | highlight and className properties. This lets you 8 | set CSS classes in the highlight markup for custom styling, such as changing 9 | the highlight color. 10 | 11 | ); 12 | 13 | const code = ``; 30 | 31 | const highlight = [ 32 | { 33 | highlight: "strawberry", 34 | className: "red", 35 | }, 36 | { 37 | highlight: "blueberry", 38 | className: "blue", 39 | }, 40 | { 41 | highlight: /ba(na)*/gi, 42 | className: "yellow", 43 | }, 44 | ]; 45 | // ], [ 46 | 47 | const CustomObject = () => { 48 | return ( 49 | 57 | ); 58 | }; 59 | 60 | export { CustomObject }; 61 | -------------------------------------------------------------------------------- /demo/src/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Navbar from "react-bootstrap/Navbar"; 3 | import Nav from "react-bootstrap/Nav"; 4 | import "bootstrap/dist/css/bootstrap.min.css"; 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 6 | import { faNpm, faGithub } from "@fortawesome/free-brands-svg-icons"; 7 | 8 | function NavBar() { 9 | // eslint-disable-next-line 10 | const github = 11 | "https://github.com/bonafideduck/react-highlight-within-textarea/"; 12 | // eslint-disable-next-line 13 | const npmjs = "https://www.npmjs.com/package/react-highlight-within-textarea"; 14 | 15 | return ( 16 | 23 |
24 | 25 | 29 | 30 |
31 |
32 |
33 | React Highlight Within Textarea 34 |
35 |
36 |
37 | 38 | 42 | 43 |
44 |
45 | ); 46 | } 47 | 48 | export { NavBar }; 49 | -------------------------------------------------------------------------------- /demo/src/Performance.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useEffect } from "react"; 3 | import HighlightWithinTextarea from "react-highlight-within-textarea"; 4 | 5 | const style = { 6 | margin: 8, 7 | border: "solid 1pt black", 8 | overflow: "scroll", 9 | textAlign: "left" as const, 10 | }; 11 | 12 | const ranges = (text: string): [start: number, end: number][] => { 13 | // Returns spans of non-spaces. 14 | let retVal: [start: number, end: number][] = []; 15 | 16 | for (let start = 0; start < text.length; start += 1) { 17 | if (text[start] !== " ") { 18 | let end = start + 1; 19 | while (end < text.length && text[end] !== " ") { 20 | end += 1; 21 | } 22 | retVal.push([start, end]); 23 | } 24 | } 25 | return retVal; 26 | }; 27 | 28 | function Performance() { 29 | const [value, setValue] = useState( 30 | String("Potato potato tomato potato. ").repeat(500) 31 | ); 32 | let highlight = ranges(value); 33 | let prevText = value; 34 | 35 | const onChange = (text:string) => { 36 | console.profile('render'); 37 | highlight = ranges(text); 38 | if (Object.is(text, prevText)) { 39 | console.profileEnd('render'); 40 | return; 41 | } 42 | prevText = text; 43 | setValue(text); 44 | } 45 | 46 | useEffect(() => { 47 | console.profileEnd('render'); 48 | }); 49 | 50 | return ( 51 |
52 |

HighlightWithinTextarea

53 |

Performance (Ranges)

54 |
55 | 60 |
61 |
62 | ); 63 | } 64 | 65 | export { Performance }; 66 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^6.6.0", 7 | "@fortawesome/free-brands-svg-icons": "^6.6.0", 8 | "@fortawesome/react-fontawesome": "^0.2.2", 9 | "@tippyjs/react": "^4.2.6", 10 | "@types/jest": "^29.5.13", 11 | "@types/node": "^22.5.4", 12 | "@types/quill": "^2.0.14", 13 | "@types/react": "^18.3.5", 14 | "@types/react-dom": "^18.3.0", 15 | "bootstrap": "^5.3.3", 16 | "draft-js": "^0.11.7", 17 | "quill": "^2.0.2", 18 | "quill-delta": "^5.1.0", 19 | "react": "link:../node_modules/react", 20 | "react-bootstrap": "^2.10.4", 21 | "react-dom": "^18.3.1", 22 | "react-highlight-within-textarea": "link:./..", 23 | "react-router-dom": "^6.26.2", 24 | "react-scripts": "5.0.1", 25 | "react-syntax-highlighter": "^15.5.0", 26 | "typescript": "^5.6.2" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "(cd .. && yarn build) && react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "publish-pages": "yarn build && gh-pages --dist=build" 34 | }, 35 | "eslintConfig": { 36 | "extends": [ 37 | "react-app", 38 | "react-app/jest" 39 | ] 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "@types/react-syntax-highlighter": "^15.5.13", 55 | "gh-pages": "^6.1.1", 56 | "@babel/plugin-proposal-private-property-in-object": "^7.21.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /demo/src/ChangeValue.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | 4 | const code = `function UpperCaseHateTomatoes() { 5 | const [value, setValue] = useState("POTATO TOMAT <- add an O here"); 6 | 7 | return ( 8 | 12 | setValue(value.toUpperCase().replaceAll("TOMATO", "ONION")) 13 | } 14 | /> 15 | ); 16 | }`; 17 | 18 | const ChangeValue = () => { 19 | return ( 20 | <> 21 | 25 | You can change the value at any time in your OnChange() handling. 26 | Note that if the value is changed, this library uses a simple 27 | algorighm to calculate the cursor position which is to shift the{" "} 28 | 29 | anchor and focus 30 | {" "} 31 | from its last position by the length that the value text changed. 32 | This technique mostly works, but this fails for complex changes. For 33 | example, if typing a (added a matching ), 34 | the cursor would move to after the ) even if the desire 35 | was to allow text within the (). 36 | 37 | } 38 | initialValue="POTATO TOMAT <- ADD AN O HERE" 39 | highlight="TOMAT" 40 | onChange={(value) => { 41 | return value.toUpperCase().replaceAll("TOMATO", "ONION"); 42 | }} 43 | code={code} 44 | codeSandbox="rhwta-dynamic-change-t869w" 45 | /> 46 | 47 | ); 48 | }; 49 | 50 | export { ChangeValue }; 51 | -------------------------------------------------------------------------------- /src/breakSpansByBlocks.ts: -------------------------------------------------------------------------------- 1 | import { ContentState } from "draft-js"; 2 | import { BlockSpan } from "./types"; 3 | import { Find } from "./types"; 4 | 5 | interface BlockData { 6 | blockStart: number, 7 | blockEnd: number, 8 | blockText: string, 9 | blockKey: string, 10 | } 11 | 12 | const extractBlockData = (contentState: ContentState, text: string): BlockData[] => { 13 | let blocks = contentState.getBlocksAsArray(); 14 | let blockData = []; 15 | let blockEnd = 0; 16 | for (const block of blocks) { 17 | let blockLength = block.getLength(); 18 | if (blockLength == 0) { 19 | continue; 20 | } 21 | let blockText = block.getText(); 22 | let blockStart = text.indexOf(blockText[0], blockEnd); 23 | blockEnd = blockStart + blockLength; 24 | blockData.push({ 25 | blockStart: blockStart, 26 | blockEnd: blockEnd, 27 | blockText: text.slice(blockStart, blockEnd), 28 | blockKey: block.getKey(), 29 | }); 30 | } 31 | return blockData; 32 | }; 33 | 34 | export const breakSpansByBlocks = ( 35 | contentState: ContentState, 36 | matches: Find[], 37 | text: string 38 | ): BlockSpan[] => { 39 | const blockData = extractBlockData(contentState, text); 40 | let newSpans = []; 41 | loop: for (const match of matches) { 42 | for (const block of blockData) { 43 | if (block.blockStart >= match.matchEnd) { 44 | continue loop; 45 | } 46 | if (block.blockEnd < match.matchStart) { 47 | continue; 48 | } 49 | const spanStart = Math.max(match.matchStart, block.blockStart); 50 | const spanEnd = Math.min(match.matchEnd, block.blockEnd); 51 | const spanText = text.slice(spanStart, spanEnd); 52 | 53 | newSpans.push({ 54 | text: text, 55 | ...match, 56 | ...block, 57 | spanStart: spanStart, 58 | spanEnd: spanEnd, 59 | spanText: spanText, 60 | }); 61 | } 62 | } 63 | return newSpans; 64 | }; 65 | 66 | -------------------------------------------------------------------------------- /demo/src/Example.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from "react"; 3 | import Row from "react-bootstrap/Row"; 4 | import Col from "react-bootstrap/Col"; 5 | import { 6 | HighlightWithinTextarea, 7 | Selection, 8 | Highlight, 9 | } from "react-highlight-within-textarea"; 10 | import { Code } from "./Code"; 11 | import { CodeSandbox } from "./CodeSandbox"; 12 | 13 | const Example = (props: { 14 | title: string; 15 | text: JSX.Element | string; 16 | initialValue: string; 17 | highlight: Highlight; 18 | code: string; 19 | codeSandbox: string; 20 | onChange?: (nextValue: string, selection?: Selection) => string; 21 | selection?: Selection; 22 | }) => { 23 | let { 24 | title, 25 | text, 26 | initialValue, 27 | highlight, 28 | code, 29 | codeSandbox, 30 | onChange, 31 | selection, 32 | } = props; 33 | const [value, setValue] = useState(initialValue); 34 | const onChange2 = (value: string, selection?: Selection) => { 35 | let newValue = value; 36 | if (onChange) { 37 | newValue = onChange(value, selection); 38 | } 39 | setValue(newValue); 40 | }; 41 | code = code || "undefined"; 42 | let style = { 43 | marginTop: 8, 44 | marginBottom: 8, 45 | border: "solid 1pt black", 46 | height: "60px", 47 | overflow: "scroll", 48 | }; 49 | 50 | return ( 51 | 52 | 53 |

{title}

54 | {text} 55 |
56 | 62 |
63 |
64 | 65 | 69 |
70 |
71 | 72 |
73 | ); 74 | }; 75 | 76 | export { Example }; 77 | -------------------------------------------------------------------------------- /demo/src/DraftPerf.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useEffect, memo } from "react"; 3 | import { Editor, EditorState, ContentState, ContentBlock, CompositeDecorator } from "draft-js"; 4 | 5 | const style = { 6 | margin: 8, 7 | border: "solid 1pt black", 8 | overflow: "scroll", 9 | textAlign: "left" as const, 10 | }; 11 | 12 | const component = memo( 13 | (props: { children?: Array; decoratedText: string }) => ( 14 | {props.children} 15 | ), 16 | (a, b) => a.decoratedText === b.decoratedText 17 | ); 18 | 19 | const strategy = ( 20 | contentBlock: ContentBlock, 21 | callback: (start: number, end: number) => void 22 | ) => { 23 | const text = contentBlock.getText(); 24 | for (let start = 0; start < text.length; start += 1) { 25 | if (text[start] !== " ") { 26 | let end = start + 1; 27 | while (end < text.length && text[end] !== " ") { 28 | end += 1; 29 | } 30 | callback(start, end); 31 | start = end; 32 | } 33 | } 34 | }; 35 | 36 | const decorator = new CompositeDecorator([{ strategy, component }]); 37 | const initialText = String("Potato potato tomato potato. ").repeat(500); 38 | 39 | function DraftPerf() { 40 | const [editorState, setEditorState] = useState(() => EditorState.createWithContent(ContentState.createFromText(initialText), decorator)); 41 | let prevEditorState = editorState; 42 | 43 | const onChange = ((editorState: EditorState) => { 44 | console.profile("render"); 45 | if (Object.is(editorState, prevEditorState)) { 46 | console.profileEnd("render"); 47 | return; 48 | } 49 | prevEditorState = editorState; 50 | setEditorState(editorState); 51 | }); 52 | 53 | useEffect(() => { 54 | console.profileEnd("render"); 55 | }); 56 | 57 | return ( 58 |
59 |

HighlightWithinTextarea

60 |

Raw Draft Performance (Ranges)

61 |
62 | 66 |
67 |
68 | ); 69 | } 70 | 71 | export { DraftPerf }; 72 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # HISTORY 2 | 3 | ## 3.2.2 4 | 5 | - Fix #189 Infinite lag error on certain regex values 6 | 7 | ## 3.2.1 8 | 9 | - Fix #175 improving performance by adding DecoratorFactory to cache previous decorators. 10 | 11 | ## 3.2.0 12 | 13 | - Mistakenly published version. 14 | 15 | ## 3.1.1 - 17-Aug-2022 16 | 17 | - Fix #157 where a crash happened when changing value with no focus 18 | 19 | ## 3.1 - 17-Jul-2022 20 | 21 | - Version 3.0.4-alpha.7 released as an official build. 22 | 23 | ## 3.0.4-alpha.7 24 | 25 | - A complete rebuild of RHWTA adding typescript and DraftJS properties 26 | 27 | ## 2.1.3 - 22-Jan-2022 28 | 29 | - Fix #85, Range highlight does not work if boundary is end of the string 30 | 31 | - `yarn upgrade --latest` 32 | 33 | ## 2.1.2 - 6-Nov-2021 34 | 35 | - Add Selection and in addition to when the value changes, call onChange when the selection changes. 36 | 37 | - exported createDecorator to allow using the highlighting from draft.js directly. 38 | 39 | ## 2.1.1 - 16-Oct-2021 40 | 41 | - Fixed #55, Issues with editing the existing text 42 | 43 | ## 2.1.0-beta - 18-July-2021 44 | - Fixed #39, Array of Two Numbers (Range) highlights on multiple rows, when new lines are used in the text 45 | - [Not backwards compatible] Changed strategy function profile to receive the text and not the draftJS ContentState 46 | - [Not backwards compatible] Changed parameters supplied to the Decorator Components to not expose draftJS data 47 | 48 | ## 2.0.0 - 14-June-2021 49 | - Draft-js based rewrite 50 | 51 | ## 1.0.2 - 9-June-2021 52 | - Documentation changes to introduce 2.0 Alpha 53 | 54 | ## V1.0.1 - 6-Mar-2021 55 | - Fixed README.md to be included in distribution. 56 | 57 | ## V1.0.0 - 6-Mar-2021 58 | 59 | - Added the "enhancement" parameter to highlight to allow 60 | wrapping with decorators of the spans. 61 | 62 | ## V0.9.12 - 13-Oct-2020 63 | 64 | - Update package.json due to dependabot alerts for react. 65 | 66 | ## V0.9.11 - 22-July-2020 67 | 68 | - Update yarn.lock due to lodash security issues. 69 | - Improve release tooling and documentation. 70 | 71 | ## V0.9.10 - 29-June-2020 72 | 73 | - Fixes scrolling. 74 | - Disables resize and adds note in README.md that it needs to be fixed. 75 | 76 | ## V0.9.9 - 23-June-2020 77 | 78 | - Initial published version. 79 | -------------------------------------------------------------------------------- /demo/src/StrategyDemo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Example } from "./Example"; 3 | import { Strategy, Callback } from "react-highlight-within-textarea"; 4 | 5 | const code = `const getSmileyDayString = (text, callback) => { 6 | const dayStrings = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; 7 | 8 | // Do nothing if there is not a smiley. 9 | if (text.indexOf(":)") !== -1) { 10 | // The below code searches for every instance of today. 11 | const textLower = text.toLowerCase(); 12 | const dayString = dayStrings[new Date().getDay()]; 13 | let index; 14 | 15 | while ((index = textLower.indexOf(dayString, index)) !== -1) { 16 | callback(index, index + dayString.length); 17 | index += dayString.length; 18 | } 19 | } 20 | }; 21 | 22 | const StrategyDemo(props) { 23 | ... 24 | return ; 28 | }`; 29 | 30 | const getSmileyDayString: Strategy = (text: string, callback: Callback) => { 31 | const dayStrings = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; 32 | 33 | if (text.indexOf(":)") !== -1) { 34 | const textLower = text.toLowerCase(); 35 | const dayString = dayStrings[new Date().getDay()]; 36 | let index; 37 | 38 | while ((index = textLower.indexOf(dayString, index)) !== -1) { 39 | callback(index, index + dayString.length); 40 | index += dayString.length; 41 | } 42 | } 43 | }; 44 | 45 | let text = ( 46 | 47 | You can use a strategy for custom logic. It is very similar to the 48 | underlying Draft.js{" "} 49 | 50 | compositeDecorator 51 | {" "} 52 | strategy except that the strategy receives the entire string and not the 53 | underlying draftjs state. 54 | 55 | ); 56 | 57 | const StrategyDemo = () => { 58 | return ( 59 | <> 60 | 68 | 69 | ); 70 | }; 71 | 72 | export { StrategyDemo }; 73 | -------------------------------------------------------------------------------- /demo/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '18 18 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | if: ${{ !github.event.act }} # skip during local actions testing 27 | runs-on: ubuntu-latest 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'javascript' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 38 | # Learn more: 39 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v2 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v1 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v1 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 https://git.io/JvXDl 62 | 63 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 64 | # and modify them (or add more) to build your code if your project 65 | # uses a compiled language 66 | 67 | #- run: | 68 | # make bootstrap 69 | # make release 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v1 73 | -------------------------------------------------------------------------------- /demo/src/QuillPerf.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect } from "react"; 2 | import Quill from "quill"; 3 | import "quill/dist/quill.core.css"; 4 | import "quill/dist/quill.snow.css"; 5 | 6 | const style = { 7 | margin: 8, 8 | border: "solid 1pt black", 9 | overflow: "scroll", 10 | textAlign: "left" as const, 11 | }; 12 | 13 | const options = { 14 | placeholder: "Compose an epic...", 15 | theme: "snow", 16 | }; 17 | 18 | function quillRangeDecorator(q: Quill, delta: unknown) { 19 | const { ops } = delta as { ops: { insert?: string }[] }; 20 | 21 | if (!ops.find((op) => op.insert)) { 22 | return; 23 | } 24 | 25 | const text = q.getText(); 26 | 27 | q.removeFormat(0, text.length - 1); 28 | 29 | let bold = true; 30 | for (let start = 0; start < text.length; start += 1) { 31 | if (text[start] !== " ") { 32 | let end = start + 1; 33 | while (end < text.length && text[end] !== " ") { 34 | end += 1; 35 | } 36 | q.formatText(start, end - start, bold ? "bold" : "italic", true); 37 | bold = !bold; 38 | start = end; 39 | } 40 | } 41 | } 42 | 43 | function QuillPerf() { 44 | const quill = useRef(null); 45 | const quillParent = useRef(null); 46 | const [delta, setDelta] = useState({}); 47 | const [prevDelta, setPrevDelta] = useState({}); 48 | const [checked, setChecked] = useState(true); 49 | const [calls, setCalls] = useState(0); 50 | 51 | useEffect(() => { 52 | if (quillParent.current && !quill.current) { 53 | const initialText = String("Potato potato tomato potato. ").repeat(500); 54 | quillParent.current.textContent = initialText; 55 | const q = new Quill(quillParent.current, options); 56 | quillRangeDecorator(q, { ops: [{ insert: 1 }] }); 57 | quill.current = q; 58 | q.on("text-change", (delta, prevDelta) => { 59 | if (checked) { 60 | quillRangeDecorator(q, delta); 61 | } 62 | setDelta(delta); 63 | setPrevDelta(prevDelta); 64 | setCalls(calls + 1); 65 | }); 66 | } 67 | }); 68 | 69 | return ( 70 |
71 |

HighlightWithinTextarea

72 |

Raw Quill Performance (Ranges)

73 | 81 |
Calls: {calls}
82 |
83 | Loading 84 |
85 | {checked && ( 86 |
87 |
Delta:
88 |
{JSON.stringify(delta, null, 2)}
89 |
PrevDelta:
90 |
{JSON.stringify(prevDelta, null, 2)}
91 |
92 | )} 93 |
94 | ); 95 | } 96 | 97 | export { QuillPerf }; 98 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | # Development Process 2 | 3 | ## Localhost Server 4 | 5 | ``` 6 | yarn start 7 | ``` 8 | 9 | ## Build and Test 10 | 11 | no testing is yet supported 12 | 13 | ## Publish New Demo 14 | 15 | ``` 16 | yarn publish-pages 17 | ``` 18 | * Manually edit each CodeSandbox ReactHighlightWithinTextarea version. 19 | 20 | ## Update the dependencies 21 | 22 | ``` 23 | rm yarn.lock demo/yarn.lock 24 | npx npm-check-updates -u --dep dev && yarn 25 | (cd demo && npx npm-check-updates -u && yarn) 26 | ``` 27 | 28 | ## Publish Update 29 | 30 | In a branch off of main, do the following: 31 | 32 | ```sh 33 | checkout main 34 | yarn upgrade --latest 35 | vi HISTORY.md 36 | git commit 37 | yarn build 38 | yarn publish --bug # --minor --major --new-version # --tag alpha # for prerelease 39 | git push --follow-tags 40 | 41 | checkout release-3.x.x 42 | git merge origin/main 43 | git push 44 | ``` 45 | 46 | * Merge this publish branch into release on github using 47 | [this link](https://github.com/bonafideduck/react-highlight-within-textarea/pull/new/develop) 48 | 49 | 50 | # Code Notes 51 | 52 | **Fixme**: This has been replaced by DecoratorFactory() 53 | 54 | ## createDecorator 55 | 56 | ### highlightToFlatStrategy 57 | 58 | Takes the highlights and breaks it down into a list of strategy 59 | and components. Returns FlatStrategy[]. 60 | 61 | ``` 62 | export type FlatStrategy = { 63 | strategy: Strategy; 64 | component?: Component; 65 | className?: string; 66 | }; 67 | ``` 68 | 69 | ### getMatches 70 | 71 | Takes text and the strategy/component highlights and returns 72 | a nonoverlapping array of matched spans. Returns Finds[]; 73 | ``` 74 | interface Find { 75 | component?: Component; 76 | className?: string; 77 | matchStart: number; 78 | matchEnd: number; 79 | matchText: string; 80 | } 81 | ``` 82 | 83 | ### breakSpansByBlocks 84 | 85 | Breaks anything flowing over a newline and splits it over the newlines. 86 | Returns BlockSpan[]. 87 | 88 | ``` 89 | export interface BlockSpan { 90 | text: string, 91 | blockStart: number, 92 | blockEnd: number, 93 | blockText: string, 94 | blockKey: string, 95 | spanStart: number; 96 | spanEnd: number; 97 | spanText: string, 98 | component?: Component; 99 | className?: string; 100 | matchStart: number; 101 | matchEnd: number; 102 | matchText: string; 103 | } 104 | ``` 105 | 106 | ### blockSpansToDecorators 107 | 108 | Changes every single match into a Decorator. Blockspans with the 109 | same `component` and `classname` and strategy will be placed in 110 | the same decorator. If a component is not specified, it will 111 | be created. The props pased to the component (if supplied) 112 | will be BlockSpan (todo: move this to an exported type?); 113 | Returns something like this: 114 | 115 | ``` 116 | { 117 | strategy: (contentBlock, callback) => void; 118 | component: (props: BlockSpan) => ReactElement; 119 | }[]; 120 | ``` 121 | 122 | ### CompositeDecorator 123 | 124 | draft-js function that takes the above decorator 125 | -------------------------------------------------------------------------------- /demo/src/Component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Example } from "./Example"; 3 | import Tippy from "@tippyjs/react"; 4 | import "tippy.js/dist/tippy.css"; 5 | 6 | const text = ( 7 | 8 | You can use a component to wrap each highlighted span. This exposes the 9 | underlying Draft.js{" "} 10 | 11 | compositeDecorator 12 | {" "} 13 | strategy. 14 | 15 | ); 16 | 17 | const code = `const ToolTip = (props) => { 18 | const objects = { contentState: "(...)", children: "(...)" }; 19 | const content =
{JSON.stringify({ ...props, ...objects }, 0, 1)}
; 20 | return ( 21 | 22 | {props.children} 23 | 24 | ); 25 | } 26 | 27 | const MultiColor = (props) => { 28 | const [color, setColor] = useState(0xff8800); 29 | const colorText = \`#\${color.toString(16)}\`; 30 | 31 | useEffect(() => { 32 | const recolor = () => setColor(0x808080 | (color + 0x081018) % 0xffffff); 33 | const timer = setInterval(recolor, 200); 34 | return () => clearInterval(timer); 35 | }); 36 | return {props.children}; 37 | }; 38 | 39 | const highlight = [ 40 | { 41 | component: MultiColor, 42 | highlight: "blue", 43 | }, 44 | { 45 | highlight: /[^ ]*berry/gi, 46 | component: ToolTip, 47 | className: "yellow", 48 | }, 49 | ]; 50 | 51 | const ComponentDemo = (props) => { 52 | return ( 53 | 57 | ); 58 | };`; 59 | 60 | const ToolTip = (props: any) => { 61 | const objects = { children: "(...)" }; 62 | const content = ( 63 |
{JSON.stringify({ ...props, ...objects }, null, 1)}
64 | ); 65 | return ( 66 | 67 | {props.children} 68 | 69 | ); 70 | }; 71 | 72 | const MultiColor = (props: any) => { 73 | const [color, setColor] = useState(0xff8800); 74 | const colorText = `#${color.toString(16)}`; 75 | 76 | useEffect(() => { 77 | const recolor = () => setColor(0x808080 | (color + 0x081018) % 0xffffff); 78 | const timer = setInterval(recolor, 200); 79 | return () => clearInterval(timer); 80 | }); 81 | return {props.children}; 82 | }; 83 | 84 | const highlight = [ 85 | { 86 | component: MultiColor, 87 | highlight: "blue", 88 | }, 89 | { 90 | highlight: /[^ ]*berry/gi, 91 | component: ToolTip, 92 | className: "yellow", 93 | }, 94 | ]; 95 | 96 | const Component = () => { 97 | const initialValue = "Here's a blueberry. Hover over this strawberry. Rasp-\n" + 98 | "berry was matched once, but split into two spans."; 99 | return ( 100 | 108 | ); 109 | }; 110 | 111 | export { Component }; 112 | -------------------------------------------------------------------------------- /src/highlightToFlatStrategy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Highlight, 3 | Strategy, 4 | Callback, 5 | FlatStrategy, 6 | CustomObject, 7 | Range, 8 | Component, 9 | } from "./types"; 10 | 11 | export const highlightToFlatStrategy = ( 12 | highlight: Highlight, 13 | classHint?: string, 14 | componentHint?: Component 15 | ): FlatStrategy[] => { 16 | let result: FlatStrategy[]; 17 | 18 | if (highlight instanceof RegExp) { 19 | result = [regExpToSAndC(highlight as RegExp, classHint, componentHint)]; 20 | } else if (typeof highlight == "string") { 21 | result = [stringToSAndC(highlight as string, classHint, componentHint)]; 22 | } else if (typeof highlight == "function") { 23 | result = [strategyToSAndC(highlight as Strategy, classHint, componentHint)]; 24 | } else if (highlight instanceof Object && "highlight" in highlight) { 25 | result = customToSAndCs( 26 | highlight as CustomObject, 27 | classHint, 28 | componentHint 29 | ); 30 | } else if (highlight instanceof Array) { 31 | if ( 32 | highlight.length === 2 && 33 | typeof highlight[0] === "number" && 34 | typeof highlight[1] === "number" 35 | ) { 36 | result = [rangeToSAndC(highlight as Range, classHint, componentHint)]; 37 | } else { 38 | result = arrayToSAndCs( 39 | highlight as Highlight[], 40 | classHint, 41 | componentHint 42 | ); 43 | } 44 | } else { 45 | throw new TypeError(`Not a Highlight type: ${highlight}`); 46 | } 47 | return result; 48 | }; 49 | 50 | function arrayToSAndCs( 51 | highlight: Highlight[], 52 | className?: string, 53 | component?: Component 54 | ): FlatStrategy[] { 55 | const sAndCs = highlight.map((h) => 56 | highlightToFlatStrategy(h, className, component) 57 | ); 58 | return Array.prototype.concat.apply([], sAndCs); 59 | } 60 | 61 | function strategyToSAndC( 62 | strategy: Strategy, 63 | className?: string, 64 | component?: Component 65 | ): FlatStrategy { 66 | return { 67 | strategy, 68 | component, 69 | className, 70 | }; 71 | } 72 | 73 | function regExpToSAndC( 74 | highlight: RegExp, 75 | className?: string, 76 | component?: Component 77 | ): FlatStrategy { 78 | const regExpStrategy = (text: string, callback: Callback) => { 79 | let matchArr, start; 80 | while ((matchArr = highlight.exec(text)) !== null) { 81 | start = matchArr.index; 82 | callback(start, start + matchArr[0].length); 83 | if (highlight.lastIndex == 0) { 84 | break; 85 | } 86 | } 87 | }; 88 | 89 | return { 90 | strategy: regExpStrategy, 91 | className, 92 | component: component, 93 | }; 94 | } 95 | 96 | function stringToSAndC( 97 | highlight: string, 98 | className?: string, 99 | component?: Component 100 | ): FlatStrategy { 101 | const stringStrategy = (text: string, callback: Callback) => { 102 | const textLower = text.toLowerCase(); 103 | const strLower = highlight.toLowerCase(); 104 | let index = 0; 105 | while (((index = textLower.indexOf(strLower, index)), index !== -1)) { 106 | callback(index, index + strLower.length); 107 | index += strLower.length; 108 | } 109 | }; 110 | 111 | return { 112 | strategy: stringStrategy, 113 | component, 114 | className, 115 | }; 116 | } 117 | 118 | function rangeToSAndC( 119 | highlight: Range, 120 | className?: string, 121 | component?: Component 122 | ): FlatStrategy { 123 | const rangeStrategy = (text: string, callback: Callback) => { 124 | const low = Math.max(0, highlight[0]); 125 | const high = Math.min(highlight[1], text.length); 126 | if (low < high) { 127 | callback(low, high); 128 | } 129 | }; 130 | 131 | return { 132 | strategy: rangeStrategy, 133 | component, 134 | className, 135 | }; 136 | } 137 | 138 | function customToSAndCs( 139 | highlight: CustomObject, 140 | className?: string, 141 | component?: Component 142 | ) { 143 | const hl = highlight.highlight; 144 | className = "className" in highlight ? highlight.className : className; 145 | component = "component" in highlight ? highlight.component : component; 146 | return highlightToFlatStrategy(hl, className, component); 147 | } 148 | -------------------------------------------------------------------------------- /demo/src/ChangeSelection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState } from "react"; 3 | import { Example } from "./Example"; 4 | import { Selection } from "react-highlight-within-textarea"; 5 | 6 | const code = `const ChangeSelection = () => { 7 | const initialValue = "Here's a blueberry. There's a strawberry. Surprise, it's a banananana!"; 8 | let [value, setValue] = useState(initialValue); 9 | let [selection, setSelection] = useState(() => new Selection(30, 40)); 10 | 11 | let anchorMinus = () => { 12 | if (state.anchor > 0) { 13 | const anchor = state.anchor - 1; 14 | const selection = new Selection(anchor, state.focus); 15 | setState({ ...state, anchor, selection }); 16 | } 17 | }; 18 | 19 | let focusPlus = () => { 20 | if (state.focus < state.value.length) { 21 | const focus = state.focus + 1; 22 | const selection = new Selection(state.anchor, focus); 23 | setState({ ...state, focus, selection }); 24 | } 25 | }; 26 | 27 | let onChange = (value, selection) => { 28 | const { anchor, focus } = selection; 29 | setState({ ...state, value, anchor, focus, selection: undefined }); 30 | return value; 31 | }; 32 | 33 | return ( 34 | <> 35 |
36 | 37 |
Anchor: {selection.anchor}
38 |
Focus: {selection.focus}
39 | 40 |
41 | 47 | 48 | ); 49 | }`; 50 | 51 | type State = { 52 | value: string; 53 | selection?: Selection; 54 | anchor: number; 55 | focus: number; 56 | }; 57 | const ChangeSelection = () => { 58 | let initialValue = 59 | "Here's a blueberry. There's a strawberry. Surprise, it's a banananana!"; 60 | 61 | let [state, setState] = useState(() => ({ 62 | value: initialValue, 63 | selection: (new Selection(30, 40) as Selection) || undefined, 64 | anchor: 30, 65 | focus: 40, 66 | })); 67 | let anchorMinus = () => { 68 | if (state.anchor > 0) { 69 | const anchor = state.anchor - 1; 70 | const selection = new Selection(anchor, state.focus); 71 | setState({ ...state, anchor, selection }); 72 | } 73 | }; 74 | let focusPlus = () => { 75 | if (state.focus < state.value.length) { 76 | const focus = state.focus + 1; 77 | const selection = new Selection(state.anchor, focus); 78 | setState({ ...state, focus, selection }); 79 | } 80 | }; 81 | let onChange = (value: string, selection?: Selection) => { 82 | const { anchor, focus } = selection || { anchor: 0, focus: 0 }; 83 | setState({ ...state, value, anchor, focus, selection: undefined }); 84 | return value; 85 | }; 86 | return ( 87 | <> 88 | 92 | 93 | ReactHighlightWithinTextarea takes selection property that allows 94 | the{" "} 95 | 96 | anchor and focus 97 | {" "} 98 | to be set. This also gets passed with the onChange event. 99 | 100 |
109 | 110 |
Anchor: {state.anchor}
111 |
Focus: {state.focus}
112 | 113 |
114 | 115 | } 116 | initialValue={state.value} 117 | highlight="berry" 118 | onChange={onChange} 119 | code={code} 120 | codeSandbox="rhwta-selection-lpkld" 121 | selection={state.selection} 122 | /> 123 | 124 | ); 125 | }; 126 | 127 | export { ChangeSelection }; 128 | -------------------------------------------------------------------------------- /demo/src/Unwrapped.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useRef } from "react"; 3 | import Row from "react-bootstrap/Row"; 4 | import Col from "react-bootstrap/Col"; 5 | import { Editor, EditorState, ContentState } from "draft-js"; 6 | import { DecoratorFactory, Selection } from "react-highlight-within-textarea"; 7 | import { Code } from "./Code"; 8 | import { CodeSandbox } from "./CodeSandbox"; 9 | 10 | const code = `const Unwrapped = () => { 11 | let [editorState, setEditorState] = useState(() => { 12 | const value = "apple watermelon banana orange mango"; 13 | const contentState = ContentState.createFromText(value); 14 | return EditorState.createWithContent(contentState); 15 | }); 16 | 17 | const highlight = ["orange", /ba(na)*/gi, [0, 5] as [number, number]]; 18 | const contentState = editorState.getCurrentContent(); 19 | const decoratorFactory = useRef(new DecoratorFactory()).current; 20 | const decorator = decoratorFactory.create(contentState, highlight); 21 | 22 | editorState = EditorState.set(editorState, { 23 | decorator: decorator, 24 | }); 25 | 26 | const value = contentState.getPlainText(); 27 | const selection = new Selection(editorState); 28 | while ((value[selection.anchor] || " ") !== " ") { 29 | selection.anchor += 1; 30 | selection.focus += 1; 31 | editorState = selection.forceSelection(editorState); 32 | } 33 | 34 | return ( 35 | 39 | ); 40 | };`; 41 | 42 | const Unwrapped = () => { 43 | let [editorState, setEditorState] = useState(() => { 44 | const value = "apple watermelon banana orange mango"; 45 | const contentState = ContentState.createFromText(value); 46 | return EditorState.createWithContent(contentState); 47 | }); 48 | 49 | const highlight = ["orange", /ba(na)*/gi, [0, 5] as [number, number]]; 50 | const contentState = editorState.getCurrentContent(); 51 | const decoratorFactory = useRef(new DecoratorFactory()).current; 52 | const decorator = decoratorFactory.create(contentState, highlight); 53 | 54 | editorState = EditorState.set(editorState, { 55 | decorator: decorator, 56 | }); 57 | 58 | const value = contentState.getPlainText(); 59 | const selection = new Selection(editorState); 60 | while ((value[selection.anchor] || " ") !== " ") { 61 | selection.anchor += 1; 62 | selection.focus += 1; 63 | editorState = selection.forceSelection(editorState); 64 | } 65 | 66 | let style = { 67 | marginTop: 8, 68 | marginBottom: 8, 69 | border: "solid 1pt black", 70 | height: "60px", 71 | overflow: "scroll", 72 | }; 73 | 74 | return ( 75 | 76 | 77 |

Unwrap and Use Draft.js Directly

78 | The fundamental capabilities that ReacthighlightWithinTextarea uses can 79 | be used directly by Draft.js{" "} 80 |
    81 |
  • 82 | DecoratorFactory contains a cache of previously created 83 | memoized decorators. Use this to generate new decorators based 84 | on the text and highlight. 85 |
  • 86 |
  • 87 | Selection extracts the text anchor and focus and changes these 88 | with forceSelection 89 |
  • 90 |
  • 91 | 92 | EditorState 93 | {" "} 94 | contains all the information about the text being edited, including 95 | history.{" "} 96 |
  • 97 |
  • 98 | 99 | Editor Component 100 | {" "} 101 | takes the EditorState. And has a rich set of properties. 102 |
  • 103 |
104 | 105 |
106 | 107 |
108 |
109 | 110 | 114 |
115 |
116 | 117 |
118 | ); 119 | }; 120 | 121 | export { Unwrapped }; 122 | -------------------------------------------------------------------------------- /src/DecoratorFactory.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import { ContentBlock, ContentState, CompositeDecorator } from "draft-js"; 3 | import { Component, Highlight, BlockSpan, Strategy } from "./types"; 4 | import { highlightToFlatStrategy } from "./highlightToFlatStrategy"; 5 | import { getMatches } from "./getMatches"; 6 | import { breakSpansByBlocks } from "./breakSpansByBlocks"; 7 | 8 | declare global { 9 | interface Console { 10 | profile: (label: string) => void; 11 | profileEnd: (label: string) => void; 12 | } 13 | } 14 | 15 | type ClassName = string; 16 | type BlockKey = string; 17 | type SpanStart = number; 18 | type ThisMap = Map< 19 | Component | undefined, 20 | Map>> 21 | >; 22 | type DecoratorCache = Map< 23 | Function | undefined, 24 | Map 25 | >; 26 | 27 | export class DecoratorFactory { 28 | map: ThisMap; 29 | decoratorCache: DecoratorCache; 30 | 31 | constructor() { 32 | this.map = new Map(); 33 | this.decoratorCache = new Map(); 34 | } 35 | 36 | updateBlockSpans(blockSpans: BlockSpan[]) { 37 | this.map.clear(); 38 | 39 | for (const blockSpan of blockSpans) { 40 | const m = this.map.get(blockSpan.component) || new Map(); 41 | this.map.set(blockSpan.component, m); 42 | const mm = m.get(blockSpan.className) || new Map(); 43 | m.set(blockSpan.className, mm); 44 | const mmm = mm.get(blockSpan.blockKey) || new Map(); 45 | mm.set(blockSpan.blockKey, mmm); 46 | mmm.set(blockSpan.spanStart - blockSpan.blockStart, blockSpan); 47 | } 48 | } 49 | 50 | componentFactory( 51 | component: Component | undefined, 52 | className: string | undefined 53 | ) { 54 | if (component == undefined) { 55 | return React.memo( 56 | (props: { 57 | children?: Array; 58 | decoratedText: string; 59 | }) => { 60 | return {props.children}; 61 | }, 62 | (a, b) => a.decoratedText == b.decoratedText 63 | ); 64 | } else { 65 | return (props: { 66 | children?: Array; 67 | blockKey: string; 68 | start: number; 69 | end: number; 70 | }) => { 71 | const block = this.map 72 | .get(component) 73 | ?.get(className) 74 | ?.get(props.blockKey) 75 | ?.get(props.start); 76 | if (!block || !block.component) { 77 | throw new Error("RHWTAInternalError"); 78 | } 79 | return {props.children}; 80 | }; 81 | } 82 | } 83 | 84 | strategyFactory( 85 | component: Component | undefined, 86 | className: ClassName | undefined 87 | ) { 88 | return ( 89 | block: ContentBlock, 90 | callback: (start: number, end: number) => void 91 | ) => { 92 | const blockSpans = 93 | this.map 94 | .get(component) 95 | ?.get(className) 96 | ?.get(block.getKey()) 97 | ?.values() || []; 98 | for (const blockSpan of blockSpans) { 99 | const start = blockSpan.spanStart - blockSpan.blockStart; 100 | const end = blockSpan.spanEnd - blockSpan.blockStart; 101 | callback(start, end); 102 | } 103 | }; 104 | } 105 | 106 | toDecorators(blockSpans: BlockSpan[]) { 107 | const decorators = []; 108 | this.updateBlockSpans(blockSpans); 109 | 110 | for (const [component, m] of this.map.entries()) { 111 | for (const [className] of m.entries()) { 112 | let decorator = this.decoratorCache.get(component)?.get(className); 113 | if (!decorator) { 114 | decorator = { 115 | component: this.componentFactory(component, className), 116 | strategy: this.strategyFactory(component, className), 117 | }; 118 | let c = this.decoratorCache.get(component) || new Map(); 119 | this.decoratorCache.set(component, c); 120 | c.set(className, decorator); 121 | } 122 | decorators.push(decorator); 123 | } 124 | } 125 | return decorators; 126 | } 127 | 128 | create(contentState: ContentState, highlight: Highlight, text?: string) { 129 | text = text || contentState.getPlainText(); 130 | 131 | const sc = highlightToFlatStrategy(highlight); 132 | const matches = getMatches(text, sc); 133 | const blockSpans = breakSpansByBlocks(contentState, matches, text); 134 | const decorators = this.toDecorators(blockSpans); 135 | const draftDecorators = new CompositeDecorator(decorators); 136 | return draftDecorators; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Selection.tsx: -------------------------------------------------------------------------------- 1 | import { EditorState, SelectionState } from "draft-js"; 2 | 3 | function editorStateToTextAnchorFocus(editorState?: EditorState) { 4 | if (!editorState) { 5 | return { anchor: 0, focus: 0 }; 6 | } 7 | let contentState = editorState.getCurrentContent(); 8 | let selection = editorState.getSelection(); 9 | let blocks = contentState.getBlocksAsArray(); 10 | let anchorKey = selection.getAnchorKey(); 11 | let anchorOffset = selection.getAnchorOffset(); 12 | let focusKey = selection.getFocusKey(); 13 | let focusOffset = selection.getFocusOffset(); 14 | let blockStart = 0; 15 | let anchor = undefined; 16 | let focus = undefined; 17 | 18 | if (anchorKey == "" && focusKey == "") { 19 | return { anchor: 0, focus: 0 }; 20 | } 21 | 22 | for (const block of blocks) { 23 | if (block.getKey() == anchorKey) { 24 | anchor = blockStart + anchorOffset; 25 | } 26 | if (block.getKey() == focusKey) { 27 | focus = blockStart + focusOffset; 28 | } 29 | if (anchor != undefined && focus != undefined) { 30 | break; 31 | } 32 | blockStart += block.getLength() + 1; 33 | } 34 | if (anchor == undefined || focus == undefined) { 35 | throw new ReferenceError("Potentially corrupt editorState."); 36 | } 37 | 38 | return { anchor, focus }; 39 | } 40 | 41 | function forceSelection( 42 | editorState: EditorState, 43 | anchor: number, 44 | focus: number 45 | ) { 46 | if (!editorState) { 47 | throw new ReferenceError("editorState is required"); 48 | } 49 | let contentState = editorState.getCurrentContent(); 50 | let blocks = contentState.getBlocksAsArray(); 51 | let blockStart = 0; 52 | let blockEnd = undefined; 53 | let anchorKey = undefined; 54 | let anchorOffset = undefined; 55 | let focusKey = undefined; 56 | let focusOffset = undefined; 57 | let block = undefined; 58 | 59 | for (block of blocks) { 60 | blockEnd = blockStart + block.getLength(); 61 | 62 | if (anchorKey == undefined && blockEnd >= anchor) { 63 | anchorKey = block.getKey(); 64 | anchorOffset = Math.max(0, anchor - blockStart); 65 | } 66 | 67 | if (focusKey == undefined && blockEnd >= focus) { 68 | focusKey = block.getKey(); 69 | focusOffset = Math.max(0, focus - blockStart); 70 | } 71 | 72 | if (anchorKey != undefined && focusKey != undefined) { 73 | break; 74 | } 75 | } 76 | 77 | if (block == undefined) { 78 | throw "Unexpected undefined block"; 79 | } 80 | 81 | if (anchorKey == undefined) { 82 | anchorKey = block.getKey(); 83 | anchorOffset = block.getLength(); 84 | } 85 | 86 | if (focusKey == undefined) { 87 | focusKey = block.getKey(); 88 | focusOffset = block.getLength(); 89 | } 90 | 91 | let selectionState = SelectionState.createEmpty(""); 92 | selectionState 93 | .set("anchorKey", anchorKey) 94 | .set("anchorOffset", anchorOffset) 95 | .set("focusKey", focusKey) 96 | .set("focusOffset", focusOffset); 97 | 98 | return EditorState.forceSelection(editorState, selectionState); 99 | } 100 | 101 | class Selection { 102 | #private; 103 | 104 | constructor(editorStateOrAnchor: number | EditorState, focus?: number) { 105 | let editorState = undefined; 106 | let anchor = -1; 107 | let initFunc = (): void => undefined; 108 | 109 | if (typeof editorStateOrAnchor == "number") { 110 | anchor = editorStateOrAnchor; 111 | focus = focus == undefined ? anchor : focus; 112 | } else { 113 | focus = -1; // Silence ts until the below init is called 114 | editorState = editorStateOrAnchor; 115 | initFunc = (): void => { 116 | this.#private = { 117 | ...this.#private, 118 | ...editorStateToTextAnchorFocus(this.#private.editorState), 119 | init: (): void => undefined, 120 | }; 121 | }; 122 | } 123 | 124 | this.#private = { 125 | editorState, 126 | anchor, 127 | focus, 128 | init: initFunc, 129 | }; 130 | } 131 | 132 | get anchor(): number { 133 | this.#private.init(); 134 | return this.#private.anchor; 135 | } 136 | 137 | set anchor(value) { 138 | this.#private.init(); 139 | this.#private.anchor = value; 140 | } 141 | 142 | get focus() { 143 | this.#private.init(); 144 | return this.#private.focus; 145 | } 146 | 147 | set focus(value) { 148 | this.#private.init(); 149 | this.#private.focus = value; 150 | } 151 | 152 | get start() { 153 | return Math.min(this.anchor, this.focus); 154 | } 155 | 156 | set start(value) { 157 | throw new ReferenceError( 158 | `start (${value}) is read only. use anchor instead` 159 | ); 160 | } 161 | 162 | get end() { 163 | return Math.max(this.anchor, this.focus); 164 | } 165 | 166 | set end(value) { 167 | throw new ReferenceError(`end (${value}) is read only. use focus instead`); 168 | } 169 | 170 | forceSelection(editorState: EditorState) { 171 | return forceSelection(editorState, this.anchor, this.focus); 172 | } 173 | 174 | getHasFocus() { 175 | let editorState = this.#private.editorState; 176 | return editorState && editorState.getSelection().getHasFocus(); 177 | } 178 | } 179 | 180 | export { Selection }; 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-highlight-within-textarea 2 | 3 | > React component for highlighting spans of text within a textarea 4 | 5 | [![NPM](https://img.shields.io/npm/v/react-highlight-within-textarea.svg)](https://www.npmjs.com/package/react-highlight-within-textarea) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) ![Yarn Test](https://github.com/bonafideduck/react-highlight-within-textarea/workflows/Yarn%20Test/badge.svg) 6 | 7 | 8 | ## Install 9 | 10 | ```bash 11 | npm install --save react-highlight-within-textarea 12 | npm install --save draft-js 13 | ``` 14 | ``` 15 | yarn add react-highlight-within-textarea 16 | yarn add draft-js 17 | ``` 18 | 19 | Note: This has a peer dependency of draft-js. 20 | 21 | ## Usage 22 | 23 | ```jsx 24 | import React from 'react'; 25 | import { useState } from 'react'; 26 | import { HighlightWithinTextarea } from 'react-highlight-within-textarea' 27 | 28 | const Example = () => { 29 | const [value, setValue] = useState("X Y Z and then XYZ"); 30 | const onChange = (value) => setValue(value); 31 | return ( 32 | 37 | ); 38 | }; 39 | ``` 40 | 41 | The highlight property accepts several different types of values to describe 42 | what will be highlighted. You can see the various ways to highlight things, 43 | along with example code, on the 44 | [documentation and demo page](https://bonafideduck.github.io/react-highlight-within-textarea/). 45 | 46 | ## Properties 47 | 48 | The following properties are used directly by this library. Additional properties are passed 49 | unmodified directly to the [Draft.js](https://draftjs.org) [Editor Component](https://draftjs.org/docs/api-reference-editor). 50 | 51 | **value**: This can either be the text value or a DraftJs [EditorState](https://draftjs.org/docs/api-reference-editor-state/#internaldocs-banner). 52 | 53 | **onChange**: This is called whenever the text value or selection changes. You must update value to accept this change. You only have to set the selection property if you want to change the current selection. 54 | 55 | **highlight**: This specifies what to highlght. For more info, see the 56 | [demo page](https://bonafideduck.github.io/react-highlight-within-textarea/). 57 | 58 | **selection**: A selection containing `anchor` and `focus` that can be use to place the cursor or set selections. 59 | 60 | **ref**: This returns a forwardRef to the underlying [Draft.js](https://draftjs.org) [Editor Component](https://draftjs.org/docs/api-reference-editor). 61 | 62 | ## Properties passed directly to Draft.js Editor Component 63 | 64 | The following properties are passed unmodified directly to the [Draft.js](https://draftjs.org) [Editor Component](https://draftjs.org/docs/api-reference-editor). 65 | 66 | [autoCapitalize](https://draftjs.org/docs/api-reference-editor#autocapitalize) 67 | [autoComplete](https://draftjs.org/docs/api-reference-editor#autocomplete) 68 | [autoCorrect](https://draftjs.org/docs/api-reference-editor#autocorrect) 69 | [editorKey](https://draftjs.org/docs/api-reference-editor#editorkey) 70 | [handleBeforeInput](https://draftjs.org/docs/api-reference-editor#handlebeforeinput) 71 | [handleDrop](https://draftjs.org/docs/api-reference-editor#handledrop) 72 | [handleDroppedFiles](https://draftjs.org/docs/api-reference-editor#handledroppedfiles) 73 | [handleKeyCommand](https://draftjs.org/docs/api-reference-editor#handlekeycommand) 74 | [handlePastedFiles](https://draftjs.org/docs/api-reference-editor#handlepastedfiles) 75 | [handlePastedText](https://draftjs.org/docs/api-reference-editor#handlepastedtext) 76 | [handleReturn](https://draftjs.org/docs/api-reference-editor#handlereturn) 77 | [keyBindingFn](https://draftjs.org/docs/api-reference-editor#keybindingfn) 78 | [onBlur](https://draftjs.org/docs/api-reference-editor#onblur) 79 | [onFocus](https://draftjs.org/docs/api-reference-editor#onfocus) 80 | [placeholder](https://draftjs.org/docs/api-reference-editor#placeholder) 81 | [readOnly](https://draftjs.org/docs/api-reference-editor#readonly) 82 | [spellCheck](https://draftjs.org/docs/api-reference-editor#spellcheck) 83 | [stripPastedStyles](https://draftjs.org/docs/api-reference-editor#strippastedstyles) 84 | [textAlignment](https://draftjs.org/docs/api-reference-editor#textalignment) 85 | [textDirectionality](https://draftjs.org/docs/api-reference-editor#textdirectionality) 86 | 87 | ## Known and Potential Issues 88 | 89 | The following have not yet been verified to work or have issues. 90 | 91 | * Form submit might not work. To be honest, I don't even know how React works with form submit buttons. 92 | * Accessible Rich Internet Applications [ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) may not be supported. 93 | * Reference forwarding probably works, but it hasn't been tested. 94 | * Tab between form elements may not work. I haven't looked into this at all. 95 | 96 | ## Changes 97 | 98 | See [HISTORY.md](https://github.com/bonafideduck/react-highlight-within-textarea/blob/main/HISTORY.md) 99 | 100 | ## License 101 | 102 | MIT © [bonafideduck](https://github.com/bonafideduck) 103 | 104 | --- 105 | 106 | * The 3.0 component was created using [this blog post](https://prateeksurana.me/blog/react-library-with-typescript/) by Prateek Surana. 107 | * The 2.0 component was created using [nwb](https://github.com/insin/nwb) 108 | * This is essentially a wrapper of [Draft.js](https://draftjs.org) 109 | * This component is a port of the [highlight-within-textarea](https://www.npmjs.com/package/highlight-within-textarea) jquery plugin to React. 110 | -------------------------------------------------------------------------------- /src/HighlightWithinTextarea.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useState, useRef, useMemo } from "react"; 3 | import { Editor, EditorState, ContentState, SelectionState } from "draft-js"; 4 | import { Selection } from "./Selection"; 5 | import { Highlight } from "./types"; 6 | import { DecoratorFactory } from "./DecoratorFactory"; 7 | interface MyRef { 8 | prevValue: string; 9 | prevEditorState: EditorState; 10 | nextValue: string; 11 | nextEditorState: EditorState; 12 | } 13 | 14 | export interface HWTAProps { 15 | value: string; 16 | onChange?: (nextValue: string, selection?: Selection) => void; 17 | highlight?: Highlight; 18 | placeholder?: string; 19 | selection?: Selection; 20 | textAlignment?: "left" | "center" | "right"; 21 | textDirectionality?: "LTR" | "RTL" | "NEUTRAL"; 22 | autoCapitalize?: string; 23 | autoComplete?: string; 24 | autoCorrect?: string; 25 | readOnly?: boolean; 26 | spellCheck?: boolean; 27 | stripPastedStyles?: boolean; 28 | editorKey?: string; 29 | handleReturn?: (e: any, editorState: EditorState) => any; 30 | handleKeyCommand?: ( 31 | command: string, 32 | editorState: EditorState, 33 | eventTimeStamp: number 34 | ) => any; 35 | handleBeforeInput?: ( 36 | chars: string, 37 | editorState: EditorState, 38 | eventTimeStamp: number 39 | ) => any; 40 | handlePastedText?: ( 41 | text: string, 42 | html?: string, 43 | editorState?: EditorState 44 | ) => any; 45 | handlePastedFiles?: (files: Array) => any; 46 | handleDroppedFiles?: (selection: SelectionState, files: Array) => any; 47 | handleDrop?: ( 48 | selection: SelectionState, 49 | dataTransfer: Object, 50 | isInternal: any 51 | ) => any; 52 | keyBindingFn?: (e: any) => any; 53 | onFocus?: (e: any) => void; 54 | onBlur?: (e: any) => void; 55 | } 56 | 57 | const HighlightWithinTextarea = React.forwardRef( 58 | (props, ref) => { 59 | let { 60 | value, 61 | onChange, 62 | highlight, 63 | placeholder = "Enter some text...", 64 | selection, 65 | } = props; 66 | const [, forceUpdate] = useState({}); 67 | const myRef: any = useRef({}); 68 | const decoratorFactory = useRef(new DecoratorFactory()); 69 | let editorState; 70 | 71 | const { prevValue, prevEditorState, nextValue, nextEditorState } = 72 | myRef.current as MyRef; 73 | 74 | if (nextValue == value) { 75 | // Change was accepted. 76 | editorState = nextEditorState; 77 | } else if (prevValue == value) { 78 | // They blocked the state change. 79 | editorState = prevEditorState; 80 | if (!selection && nextValue) { 81 | selection = new Selection(editorState); 82 | selection.focus = Math.max(selection.anchor, selection.focus); 83 | selection.anchor = selection.focus; 84 | } 85 | } else if (prevEditorState) { 86 | // They chose a whole new value. 87 | const contentState = ContentState.createFromText(value); 88 | const changeType = "change-block-data"; 89 | editorState = EditorState.push(prevEditorState, contentState, changeType); 90 | if (!selection) { 91 | let fixedValue, offset; 92 | if (nextEditorState) { 93 | selection = new Selection(nextEditorState); 94 | fixedValue = value.replaceAll("\r\n", "\n"); 95 | offset = fixedValue.length - nextValue.length; 96 | } else { 97 | selection = new Selection(prevEditorState); 98 | fixedValue = value.replaceAll("\r\n", "\n"); 99 | offset = fixedValue.length - prevValue.length; 100 | } 101 | selection.anchor += offset; 102 | selection.focus += offset; 103 | } 104 | } else { 105 | // First time in here. 106 | const contentState = ContentState.createFromText(value); 107 | editorState = EditorState.createWithContent(contentState); 108 | } 109 | 110 | const contentState = editorState.getCurrentContent(); 111 | let decorator; 112 | decorator = useMemo( 113 | () => 114 | highlight && 115 | decoratorFactory.current.create(contentState, highlight as Highlight, value), 116 | [contentState, highlight, value] 117 | ); 118 | 119 | editorState = EditorState.set(editorState, { 120 | decorator: decorator, 121 | }); 122 | 123 | if (selection) { 124 | editorState = selection.forceSelection(editorState); 125 | } 126 | 127 | myRef.current = { 128 | prevEditorState: editorState, 129 | prevValue: value, 130 | }; 131 | 132 | const onDraftChange = (nextEditorState: EditorState) => { 133 | const nextValue = nextEditorState.getCurrentContent().getPlainText(); 134 | myRef.current = { 135 | ...myRef.current, 136 | nextEditorState: nextEditorState, 137 | nextValue: nextValue, 138 | }; 139 | let selection = undefined; 140 | 141 | selection = new Selection(nextEditorState); 142 | if (onChange) { 143 | onChange(nextValue, selection); 144 | } 145 | forceUpdate({}); 146 | }; 147 | 148 | const newProps = { ...props }; 149 | delete newProps.highlight; 150 | delete newProps.selection; 151 | delete newProps.placeholder; 152 | delete newProps.onChange; 153 | delete (newProps as { value?: string }).value; 154 | 155 | return ( 156 | 163 | ); 164 | } 165 | ); 166 | 167 | export default HighlightWithinTextarea; 168 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/draft-js@>=0.11.18": 6 | version "0.11.18" 7 | resolved "https://registry.yarnpkg.com/@types/draft-js/-/draft-js-0.11.18.tgz#f2cad2178987fdd444827a2f7809a653f82a70ad" 8 | integrity sha512-lP6yJ+EKv5tcG1dflWgDKeezdwBa8wJ7KkiNrrHqXuXhl/VGes1SKjEfKHDZqOz19KQbrAhFvNhDPWwnQXYZGQ== 9 | dependencies: 10 | "@types/react" "*" 11 | immutable "~3.7.4" 12 | 13 | "@types/prop-types@*": 14 | version "15.7.12" 15 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" 16 | integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== 17 | 18 | "@types/react@*": 19 | version "18.3.5" 20 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.5.tgz#5f524c2ad2089c0ff372bbdabc77ca2c4dbadf8f" 21 | integrity sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA== 22 | dependencies: 23 | "@types/prop-types" "*" 24 | csstype "^3.0.2" 25 | 26 | ansi-regex@^5.0.1: 27 | version "5.0.1" 28 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 29 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 30 | 31 | ansi-styles@^4.0.0, ansi-styles@^4.1.0: 32 | version "4.3.0" 33 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 34 | integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 35 | dependencies: 36 | color-convert "^2.0.1" 37 | 38 | asap@~2.0.3: 39 | version "2.0.6" 40 | resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" 41 | integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== 42 | 43 | chalk@^4.1.2: 44 | version "4.1.2" 45 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" 46 | integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== 47 | dependencies: 48 | ansi-styles "^4.1.0" 49 | supports-color "^7.1.0" 50 | 51 | cliui@^8.0.1: 52 | version "8.0.1" 53 | resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" 54 | integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== 55 | dependencies: 56 | string-width "^4.2.0" 57 | strip-ansi "^6.0.1" 58 | wrap-ansi "^7.0.0" 59 | 60 | color-convert@^2.0.1: 61 | version "2.0.1" 62 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 63 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 64 | dependencies: 65 | color-name "~1.1.4" 66 | 67 | color-name@~1.1.4: 68 | version "1.1.4" 69 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 70 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 71 | 72 | concurrently@^9.0.1: 73 | version "9.0.1" 74 | resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.0.1.tgz#01e171bf6c7af0c022eb85daef95bff04d8185aa" 75 | integrity sha512-wYKvCd/f54sTXJMSfV6Ln/B8UrfLBKOYa+lzc6CHay3Qek+LorVSBdMVfyewFhRbH0Rbabsk4D+3PL/VjQ5gzg== 76 | dependencies: 77 | chalk "^4.1.2" 78 | lodash "^4.17.21" 79 | rxjs "^7.8.1" 80 | shell-quote "^1.8.1" 81 | supports-color "^8.1.1" 82 | tree-kill "^1.2.2" 83 | yargs "^17.7.2" 84 | 85 | core-js@^3.6.4: 86 | version "3.38.1" 87 | resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.38.1.tgz#aa375b79a286a670388a1a363363d53677c0383e" 88 | integrity sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw== 89 | 90 | cross-fetch@^3.0.4: 91 | version "3.1.8" 92 | resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" 93 | integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== 94 | dependencies: 95 | node-fetch "^2.6.12" 96 | 97 | csstype@^3.0.2: 98 | version "3.1.3" 99 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" 100 | integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== 101 | 102 | draft-js@>=0.11.7: 103 | version "0.11.7" 104 | resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.11.7.tgz#be293aaa255c46d8a6647f3860aa4c178484a206" 105 | integrity sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg== 106 | dependencies: 107 | fbjs "^2.0.0" 108 | immutable "~3.7.4" 109 | object-assign "^4.1.1" 110 | 111 | emoji-regex@^8.0.0: 112 | version "8.0.0" 113 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 114 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 115 | 116 | escalade@^3.1.1: 117 | version "3.2.0" 118 | resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" 119 | integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== 120 | 121 | fbjs-css-vars@^1.0.0: 122 | version "1.0.2" 123 | resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" 124 | integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== 125 | 126 | fbjs@^2.0.0: 127 | version "2.0.0" 128 | resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-2.0.0.tgz#01fb812138d7e31831ed3e374afe27b9169ef442" 129 | integrity sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ== 130 | dependencies: 131 | core-js "^3.6.4" 132 | cross-fetch "^3.0.4" 133 | fbjs-css-vars "^1.0.0" 134 | loose-envify "^1.0.0" 135 | object-assign "^4.1.0" 136 | promise "^7.1.1" 137 | setimmediate "^1.0.5" 138 | ua-parser-js "^0.7.18" 139 | 140 | get-caller-file@^2.0.5: 141 | version "2.0.5" 142 | resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" 143 | integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== 144 | 145 | has-flag@^4.0.0: 146 | version "4.0.0" 147 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" 148 | integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== 149 | 150 | immutable@~3.7.4: 151 | version "3.7.6" 152 | resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" 153 | integrity sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw== 154 | 155 | is-fullwidth-code-point@^3.0.0: 156 | version "3.0.0" 157 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" 158 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 159 | 160 | "js-tokens@^3.0.0 || ^4.0.0": 161 | version "4.0.0" 162 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 163 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 164 | 165 | lodash@^4.17.21: 166 | version "4.17.21" 167 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 168 | integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 169 | 170 | loose-envify@^1.0.0, loose-envify@^1.1.0: 171 | version "1.4.0" 172 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 173 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 174 | dependencies: 175 | js-tokens "^3.0.0 || ^4.0.0" 176 | 177 | node-fetch@^2.6.12: 178 | version "2.7.0" 179 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" 180 | integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== 181 | dependencies: 182 | whatwg-url "^5.0.0" 183 | 184 | object-assign@^4.1.0, object-assign@^4.1.1: 185 | version "4.1.1" 186 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 187 | integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== 188 | 189 | promise@^7.1.1: 190 | version "7.3.1" 191 | resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" 192 | integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== 193 | dependencies: 194 | asap "~2.0.3" 195 | 196 | react-dom@>=18.3.1: 197 | version "18.3.1" 198 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" 199 | integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== 200 | dependencies: 201 | loose-envify "^1.1.0" 202 | scheduler "^0.23.2" 203 | 204 | react@>=18.3.1: 205 | version "18.3.1" 206 | resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" 207 | integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== 208 | dependencies: 209 | loose-envify "^1.1.0" 210 | 211 | require-directory@^2.1.1: 212 | version "2.1.1" 213 | resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" 214 | integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== 215 | 216 | rxjs@^7.8.1: 217 | version "7.8.1" 218 | resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" 219 | integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== 220 | dependencies: 221 | tslib "^2.1.0" 222 | 223 | scheduler@^0.23.2: 224 | version "0.23.2" 225 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" 226 | integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== 227 | dependencies: 228 | loose-envify "^1.1.0" 229 | 230 | setimmediate@^1.0.5: 231 | version "1.0.5" 232 | resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" 233 | integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== 234 | 235 | shell-quote@^1.8.1: 236 | version "1.8.1" 237 | resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" 238 | integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== 239 | 240 | string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 241 | version "4.2.3" 242 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 243 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 244 | dependencies: 245 | emoji-regex "^8.0.0" 246 | is-fullwidth-code-point "^3.0.0" 247 | strip-ansi "^6.0.1" 248 | 249 | strip-ansi@^6.0.0, strip-ansi@^6.0.1: 250 | version "6.0.1" 251 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 252 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 253 | dependencies: 254 | ansi-regex "^5.0.1" 255 | 256 | supports-color@^7.1.0: 257 | version "7.2.0" 258 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" 259 | integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== 260 | dependencies: 261 | has-flag "^4.0.0" 262 | 263 | supports-color@^8.1.1: 264 | version "8.1.1" 265 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" 266 | integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== 267 | dependencies: 268 | has-flag "^4.0.0" 269 | 270 | tr46@~0.0.3: 271 | version "0.0.3" 272 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 273 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 274 | 275 | tree-kill@^1.2.2: 276 | version "1.2.2" 277 | resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" 278 | integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== 279 | 280 | tslib@^2.1.0: 281 | version "2.7.0" 282 | resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" 283 | integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== 284 | 285 | typescript@^5.6.2: 286 | version "5.6.2" 287 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" 288 | integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== 289 | 290 | ua-parser-js@^0.7.18: 291 | version "0.7.39" 292 | resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.39.tgz#c71efb46ebeabc461c4612d22d54f88880fabe7e" 293 | integrity sha512-IZ6acm6RhQHNibSt7+c09hhvsKy9WUr4DVbeq9U8o71qxyYtJpQeDxQnMrVqnIFMLcQjHO0I9wgfO2vIahht4w== 294 | 295 | webidl-conversions@^3.0.0: 296 | version "3.0.1" 297 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 298 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 299 | 300 | whatwg-url@^5.0.0: 301 | version "5.0.0" 302 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 303 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 304 | dependencies: 305 | tr46 "~0.0.3" 306 | webidl-conversions "^3.0.0" 307 | 308 | wrap-ansi@^7.0.0: 309 | version "7.0.0" 310 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 311 | integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 312 | dependencies: 313 | ansi-styles "^4.0.0" 314 | string-width "^4.1.0" 315 | strip-ansi "^6.0.0" 316 | 317 | y18n@^5.0.5: 318 | version "5.0.8" 319 | resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" 320 | integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== 321 | 322 | yargs-parser@^21.1.1: 323 | version "21.1.1" 324 | resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" 325 | integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== 326 | 327 | yargs@^17.7.2: 328 | version "17.7.2" 329 | resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" 330 | integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== 331 | dependencies: 332 | cliui "^8.0.1" 333 | escalade "^3.1.1" 334 | get-caller-file "^2.0.5" 335 | require-directory "^2.1.1" 336 | string-width "^4.2.3" 337 | y18n "^5.0.5" 338 | yargs-parser "^21.1.1" 339 | --------------------------------------------------------------------------------