├── packages ├── highlightable-input │ ├── src │ │ ├── vite-env.d.ts │ │ ├── styles │ │ │ ├── themes │ │ │ │ ├── antd.css │ │ │ │ ├── fluent.css │ │ │ │ ├── bootstrap.css │ │ │ │ ├── arco.css │ │ │ │ ├── light.css │ │ │ │ ├── semi.css │ │ │ │ ├── atlassian.css │ │ │ │ ├── chakra.css │ │ │ │ ├── carbon.css │ │ │ │ ├── kongponents.css │ │ │ │ ├── spectrum.css │ │ │ │ └── lightning.css │ │ │ └── style.css │ │ ├── utils.ts │ │ ├── history.ts │ │ ├── vue.ts │ │ ├── react.tsx │ │ ├── cursor.ts │ │ ├── browser.ts │ │ └── index.ts │ ├── tsup.config.ts │ ├── tsconfig.json │ ├── scripts │ │ └── css.mjs │ └── package.json └── site │ ├── src │ ├── vue │ │ ├── index.ts │ │ └── App.vue │ ├── vite-env.d.ts │ ├── react │ │ ├── index.tsx │ │ └── App.tsx │ ├── public │ │ ├── vue.svg │ │ ├── react.svg │ │ └── hi.svg │ ├── index.css │ ├── index.ts │ ├── rules.ts │ └── index.html │ ├── tsconfig.node.json │ ├── vite.config.ts │ ├── package.json │ └── tsconfig.json ├── pnpm-workspace.yaml ├── .editorconfig ├── .prettierrc ├── package.json ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md └── pnpm-lock.yaml /packages/highlightable-input/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/** 3 | 4 | onlyBuiltDependencies: 5 | - esbuild 6 | -------------------------------------------------------------------------------- /packages/site/src/vue/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | 4 | export default function mount() { 5 | createApp(App).mount("#vue-app"); 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /packages/site/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "none", 5 | "overrides": [ 6 | { 7 | "files": "*.css", 8 | "options": { 9 | "singleQuote": false 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/site/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/site/src/react/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client' 2 | import App from './App' 3 | 4 | export default function mount() { 5 | ReactDOM.createRoot( 6 | document.getElementById('react-app') as HTMLElement 7 | ).render() 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@highlightable-input/repo", 3 | "version": "0.4.1", 4 | "private": true, 5 | "scripts": { 6 | "release": "bumpp package.json packages/*/package.json --commit --push --tag && pnpm -r publish --access public" 7 | }, 8 | "devDependencies": { 9 | "bumpp": "^9.11.1" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/site/src/public/vue.svg: -------------------------------------------------------------------------------- 1 | 2 | Vue Logo 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/site/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import react from '@vitejs/plugin-react' 4 | 5 | export default defineConfig({ 6 | root: './src', 7 | resolve: { 8 | conditions: ['dev'] 9 | }, 10 | build: { 11 | outDir: '../dist', 12 | emptyOutDir: true 13 | }, 14 | plugins: [vue(), react()] 15 | }) 16 | -------------------------------------------------------------------------------- /packages/highlightable-input/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: 'src/index.ts', 6 | vue: 'src/vue.ts', 7 | react: 'src/react.tsx' 8 | }, 9 | format: ['esm'], 10 | external: ['vue', 'react', './index.ts'], 11 | splitting: false, 12 | sourcemap: false, 13 | clean: true, 14 | minify: true, 15 | dts: true 16 | }) 17 | -------------------------------------------------------------------------------- /packages/site/src/public/react.svg: -------------------------------------------------------------------------------- 1 | 2 | React Logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Dist files 27 | packages/highlightable-input/*.js 28 | packages/highlightable-input/*.d.ts 29 | packages/highlightable-input/*.css 30 | packages/highlightable-input/themes 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4.1 4 | 5 | - Escape raw input before highlighting to prevent XSS while preserving rich markup. 6 | 7 | ## 0.4.0 8 | 9 | - Added `data-rows` attribute support for vanilla version and `rows` prop for Vue/React components. 10 | 11 | ## 0.3.0 12 | 13 | - Added a new theme `kongponents`. 14 | 15 | ## 0.2.3 16 | 17 | - Removed `console.log` during user input. 18 | 19 | ## 0.2.2 20 | 21 | - Replacer should receive all available arguments. 22 | 23 | ## 0.2.1 24 | 25 | - Fix placeholder style. 26 | 27 | ## 0.2.0 28 | 29 | - Add undo/redo support. 30 | 31 | ## 0.1.0 32 | 33 | - Initial release. 34 | -------------------------------------------------------------------------------- /packages/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@highlightable-input/site", 3 | "version": "0.4.1", 4 | "private": "true", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "devDependencies": { 11 | "@types/react": "^18.3.23", 12 | "@types/react-dom": "^18.3.7", 13 | "@vercel/analytics": "^0.1.11", 14 | "@vitejs/plugin-react": "^4.5.2", 15 | "@vitejs/plugin-vue": "^4.6.2", 16 | "highlightable-input": "workspace:*", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1", 19 | "typescript": "^5.8.3", 20 | "vite": "^4.5.14", 21 | "vue": "^3.5.16", 22 | "vue-tsc": "^1.8.27" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Bundler", 8 | "strict": true, 9 | "jsx": "react-jsx", 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "skipLibCheck": true, 18 | "allowJs": true, 19 | "customConditions": ["dev"] 20 | }, 21 | "include": [ 22 | "src" 23 | ], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/highlightable-input/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "isolatedModules": true, 7 | "jsx": "react-jsx", 8 | "lib": ["ESNext", "DOM"], 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "noEmit": true, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "target": "ESNext", 19 | "useDefineForClassFields": true, 20 | "customConditions": ["dev"] 21 | }, 22 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/highlightable-input/scripts/css.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import url from 'node:url' 4 | import fg from 'fast-glob' 5 | import { transform } from 'lightningcss' 6 | 7 | function resolve(...segments) { 8 | return url.fileURLToPath(new URL(path.join(...segments), import.meta.url)) 9 | } 10 | 11 | const ROOT_DIR = resolve('..') 12 | const STYLES_DIR = resolve(ROOT_DIR, 'src/styles') 13 | const OUT_DIR = resolve(ROOT_DIR, 'dist') 14 | const THEMES_OUT_DIR = resolve(OUT_DIR, 'themes') 15 | 16 | fs.mkdirSync(THEMES_OUT_DIR, { recursive: true }) 17 | 18 | fg.sync('**/*.css', { cwd: STYLES_DIR }).forEach((file) => { 19 | const { code } = transform({ 20 | filename: path.join(STYLES_DIR, file), 21 | code: fs.readFileSync(path.join(STYLES_DIR, file)), 22 | minify: true 23 | }) 24 | 25 | fs.writeFileSync(path.join(OUT_DIR, file), code, 'utf8') 26 | }) 27 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/antd.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="antd"] { 2 | width: 100%; 3 | padding: 4px 11px; 4 | background-color: #fff; 5 | border: 1px solid #d9d9d9; 6 | border-radius: 6px; 7 | font-size: 14px; 8 | color: rgba(0, 0, 0, 0.88); 9 | line-height: 1.5714285714285714; 10 | transition: all 0.2s; 11 | } 12 | 13 | highlightable-input[data-theme="antd"][aria-placeholder]::before { 14 | color: transparent; 15 | } 16 | 17 | highlightable-input[data-theme="antd"][aria-placeholder]:empty::before { 18 | color: rgba(0, 0, 0, 0.25); 19 | } 20 | 21 | highlightable-input[data-theme="antd"]:hover { 22 | border-color: #4096ff; 23 | } 24 | 25 | highlightable-input[data-theme="antd"]:focus { 26 | border-color: #4096ff; 27 | box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1); 28 | outline: none; 29 | } 30 | 31 | highlightable-input[data-theme="antd"][aria-disabled="true"] { 32 | background-color: rgba(0, 0, 0, 0.04); 33 | cursor: not-allowed; 34 | color: rgba(0, 0, 0, 0.25); 35 | } 36 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function setContentEditable(el: HTMLElement, value: boolean) { 2 | if (value) { 3 | el.contentEditable = 'true' 4 | } else { 5 | // el.contentEditable = "false" doesn't seem to work in Safari 6 | el.removeAttribute('contenteditable') 7 | } 8 | } 9 | 10 | export function setFocusable(el: HTMLElement, value: boolean) { 11 | if (value) { 12 | el.tabIndex = 0 13 | } else { 14 | el.removeAttribute('tabindex') 15 | } 16 | } 17 | 18 | export function setRows(el: HTMLElement, value: number) { 19 | el.style.setProperty('--rows', value.toString()) 20 | } 21 | 22 | const ESCAPE_LOOKUP: Record = { 23 | '&': '&', 24 | '<': '<', 25 | '>': '>', 26 | '"': '"', 27 | "'": ''' 28 | } 29 | 30 | const ESCAPE_PATTERN = /[&<>"']/g 31 | 32 | export function escapeHTML(text: string): string { 33 | ESCAPE_PATTERN.lastIndex = 0 34 | return ESCAPE_PATTERN.test(text) 35 | ? text.replace(ESCAPE_PATTERN, (char) => ESCAPE_LOOKUP[char]) 36 | : text 37 | } 38 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/style.css: -------------------------------------------------------------------------------- 1 | highlightable-input { 2 | box-sizing: border-box; 3 | display: inline-block; 4 | overflow: hidden; 5 | width: 320px; 6 | white-space: pre; 7 | text-overflow: ellipsis; 8 | word-wrap: break-word; 9 | font-size: 14px; 10 | cursor: text; 11 | } 12 | 13 | highlightable-input[contenteditable] { 14 | -webkit-user-modify: read-write-plaintext-only; 15 | } 16 | 17 | highlightable-input[aria-multiline="true"] { 18 | overflow: auto; 19 | height: auto; 20 | white-space: pre-wrap; 21 | resize: both; 22 | } 23 | 24 | highlightable-input[aria-placeholder]::before { 25 | content: attr(aria-placeholder); 26 | position: absolute; 27 | color: transparent; 28 | pointer-events: none; 29 | } 30 | 31 | highlightable-input:empty::after { 32 | content: "\200b"; 33 | } 34 | 35 | highlightable-input[aria-disabled="true"] { 36 | cursor: default; 37 | } 38 | 39 | highlightable-input:focus { 40 | text-overflow: clip; 41 | outline: none; 42 | } 43 | 44 | highlightable-input mark { 45 | color: inherit; 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 GU Yiling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/fluent.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="fluent"] { 2 | height: 32px; 3 | padding: calc((30px - 1.4em) / 2) 8px; 4 | border: 1px solid rgb(96, 94, 92); 5 | border-radius: 2px; 6 | background: rgb(255, 255, 255); 7 | font-weight: 400; 8 | line-height: 1.4; 9 | color: rgb(50, 49, 48); 10 | } 11 | 12 | highlightable-input[data-theme="fluent"][aria-multiline] { 13 | height: auto; 14 | min-height: 60px; 15 | padding: 6px 8px; 16 | line-height: 17px; 17 | } 18 | 19 | highlightable-input[data-theme="fluent"][aria-placeholder]::before { 20 | color: transparent; 21 | } 22 | 23 | highlightable-input[data-theme="fluent"][aria-placeholder]:empty::before { 24 | color: rgb(96, 94, 92); 25 | } 26 | 27 | highlightable-input[data-theme="fluent"]:hover { 28 | border-color: rgb(50, 49, 48); 29 | } 30 | 31 | highlightable-input[data-theme="fluent"]:focus { 32 | border: 1px solid rgb(0, 120, 212); 33 | box-shadow: inset 0 0 0 1px rgb(0, 120, 212); 34 | } 35 | 36 | highlightable-input[data-theme="fluent"][aria-disabled="true"] { 37 | background-color: rgba(0, 0, 0, 0.04); 38 | cursor: not-allowed; 39 | color: rgba(0, 0, 0, 0.25); 40 | } 41 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/history.ts: -------------------------------------------------------------------------------- 1 | export type HistoryEntry = Readonly<{ 2 | value: string 3 | offsets: readonly [number, number] 4 | source: 'initial' | 'space' | 'normal' | 'single' | 'delete' 5 | }> 6 | 7 | type History = Readonly<{ 8 | insert(entry: HistoryEntry): void 9 | update(entry: Partial): void 10 | undo(callback: (entry: HistoryEntry) => void): void 11 | redo(callback: (entry: HistoryEntry) => void): void 12 | readonly current: HistoryEntry 13 | }> 14 | 15 | export function createHistory(initial: HistoryEntry): History { 16 | const stack: HistoryEntry[] = [initial] 17 | let currentIndex: number = 0 18 | 19 | return { 20 | insert(entry) { 21 | stack.splice(currentIndex + 1, stack.length, entry) 22 | currentIndex = stack.length - 1 23 | }, 24 | update(entry) { 25 | Object.assign(stack[currentIndex], entry) 26 | }, 27 | undo(callback) { 28 | if (currentIndex !== 0) { 29 | callback(stack[--currentIndex]) 30 | } 31 | }, 32 | redo(callback) { 33 | if (currentIndex !== stack.length - 1) { 34 | callback(stack[++currentIndex]) 35 | } 36 | }, 37 | get current() { 38 | return stack[currentIndex] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/bootstrap.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="bootstrap"] { 2 | display: block; 3 | width: 100%; 4 | padding: 0.375rem 0.75rem; 5 | font-size: 1rem; 6 | font-weight: 400; 7 | line-height: 1.5; 8 | color: #212529; 9 | background-color: #fff; 10 | background-clip: padding-box; 11 | border: 1px solid #ced4da; 12 | appearance: none; 13 | border-radius: 0.375rem; 14 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 15 | } 16 | 17 | highlightable-input[data-theme="bootstrap"][aria-multiline="true"] { 18 | min-height: calc(1.5em + 0.75rem + 2px); 19 | resize: vertical; 20 | } 21 | 22 | highlightable-input[data-theme="bootstrap"][aria-placeholder]::before { 23 | opacity: 0; 24 | } 25 | 26 | highlightable-input[data-theme="bootstrap"][aria-placeholder]:empty::before { 27 | color: #6c757d; 28 | opacity: 1; 29 | } 30 | 31 | highlightable-input[data-theme="bootstrap"]:focus { 32 | color: #212529; 33 | background-color: #fff; 34 | border-color: #86b7fe; 35 | outline: 0; 36 | box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%); 37 | } 38 | 39 | highlightable-input[data-theme="bootstrap"][aria-disabled="true"] { 40 | background-color: #e9ecef; 41 | opacity: 1; 42 | } 43 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/arco.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="arco"] { 2 | width: 100%; 3 | border: 1px solid transparent; 4 | border-radius: var(--border-radius-small, 2px); 5 | padding: 4px 12px; 6 | background-color: var(--color-fill-2, #f2f3f5); 7 | line-height: 1.5715; 8 | color: var(--color-text-1, #1d2129); 9 | font-size: 14px; 10 | transition: color 0.1s linear, border-color 0.1s linear, 11 | background-color 0.1s linear; 12 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 13 | } 14 | 15 | highlightable-input[data-theme="arco"][aria-placeholder]::before { 16 | color: transparent; 17 | } 18 | 19 | highlightable-input[data-theme="arco"][aria-placeholder]:empty::before { 20 | color: var(--color-text-3, #86909c); 21 | } 22 | 23 | highlightable-input[data-theme="arco"]:hover { 24 | background-color: var(--color-fill-3, #e5e6eb); 25 | } 26 | 27 | highlightable-input[data-theme="arco"]:focus { 28 | border-color: rgb(var(--primary-6, 22, 93, 255)); 29 | background-color: var(--color-bg-2, #fff); 30 | } 31 | 32 | highlightable-input[data-theme="arco"][aria-disabled="true"] { 33 | background-color: var(--color-fill-2, #f2f3f5); 34 | cursor: not-allowed; 35 | color: var(--color-text-4, #c9cdd4); 36 | border-color: transparent; 37 | } 38 | -------------------------------------------------------------------------------- /packages/site/src/vue/App.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 50 | -------------------------------------------------------------------------------- /packages/site/src/public/hi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/light.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="light"] { 2 | width: 300px; 3 | height: 32px; 4 | padding: 6px 12px; 5 | border: 1px solid #d3d9e6; 6 | background-color: #fff; 7 | color: #282c33; 8 | border-radius: 4px; 9 | font-size: 14px; 10 | line-height: 18px; 11 | transition-property: background-color, border-color, color, box-shadow; 12 | transition-duration: 0.2s; 13 | } 14 | 15 | 16 | highlightable-input[data-theme="light"][aria-multiline="true"] { 17 | height: auto; 18 | line-height: 1.4; 19 | min-height: 76.8px; 20 | padding: 8px 12px; 21 | resize: none; 22 | overflow: auto; 23 | } 24 | 25 | highlightable-input[data-theme="light"][aria-placeholder]::before { 26 | color: transparent; 27 | } 28 | 29 | highlightable-input[data-theme="light"][aria-placeholder]:empty::before { 30 | color: #848b99; 31 | } 32 | 33 | highlightable-input[data-theme="light"]:hover { 34 | border-color: #a8b0bf; 35 | } 36 | 37 | highlightable-input[data-theme="light"][aria-readonly="true"] { 38 | border-color: #d3d9e6; 39 | background-color: #f6f7fa; 40 | } 41 | 42 | highlightable-input[data-theme="light"]:focus { 43 | border: 1px solid #0052cc; 44 | box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.2); 45 | } 46 | 47 | highlightable-input[data-theme="light"][aria-disabled="true"] { 48 | background-color: #f6f7fa; 49 | border-color: #e2e6f0; 50 | color: #a8b0bf; 51 | cursor: not-allowed; 52 | } 53 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/semi.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="semi"] { 2 | width: 100%; 3 | height: 32px; 4 | border: 1px solid transparent; 5 | border-radius: var(--semi-border-radius-small, 3px); 6 | padding: 0 12px; 7 | background-color: var(--semi-color-fill-0, rgba(46, 50, 56, 0.05)); 8 | line-height: 30px; 9 | font-size: 14px; 10 | color: var(--semi-color-text-0, #1c1f23); 11 | } 12 | 13 | highlightable-input[data-theme="semi"][aria-multiline="true"] { 14 | height: auto; 15 | padding: 5px 12px; 16 | line-height: 20px; 17 | } 18 | 19 | highlightable-input[data-theme="semi"][aria-placeholder]::before { 20 | color: transparent; 21 | } 22 | 23 | highlightable-input[data-theme="semi"][aria-placeholder]:empty::before { 24 | color: var(--semi-color-text-2, rgba(28, 31, 35, 0.62)); 25 | } 26 | 27 | highlightable-input[data-theme="semi"]:hover { 28 | background-color: var(--semi-color-fill-1, rgba(46, 50, 56, 0.09)); 29 | } 30 | 31 | highlightable-input[data-theme="semi"]:focus { 32 | background-color: var(--semi-color-fill-0, rgba(46, 50, 56, 0.05)); 33 | border: 1px solid var(--semi-color-focus-border, #0064fa); 34 | } 35 | 36 | highlightable-input[data-theme="semi"][aria-disabled="true"] { 37 | background-color: var(--semi-color-disabled-fill, rgba(46, 50, 56, 0.04)); 38 | cursor: not-allowed; 39 | color: var(--semi-color-disabled-text, rgba(28, 31, 35, 0.35)); 40 | -webkit-text-fill-color: currentColor; 41 | } 42 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/atlassian.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="atlassian"] { 2 | height: calc(2.57em + 4px); 3 | padding: 8px 6px; 4 | background-color: var(--ds-background-input, #fafbfc); 5 | border: 2px solid var(--ds-border-input, #dfe1e6); 6 | color: var(--ds-text, #091e42); 7 | border-radius: 3px; 8 | font-size: 14px; 9 | line-height: 1.42857; 10 | max-width: 100%; 11 | transition: background-color 0.2s ease-in-out 0s, 12 | border-color 0.2s ease-in-out 0s; 13 | vertical-align: top; 14 | } 15 | 16 | highlightable-input[data-theme="atlassian"][aria-multiline="true"] { 17 | min-height: 56px; 18 | padding: 6px; 19 | } 20 | 21 | highlightable-input[data-theme="atlassian"][aria-placeholder]::before { 22 | color: transparent; 23 | } 24 | 25 | highlightable-input[data-theme="atlassian"][aria-placeholder]:empty::before { 26 | color: var(--ds-text-subtlest, #7a869a); 27 | } 28 | 29 | highlightable-input[data-theme="atlassian"]:hover { 30 | background-color: var(--ds-background-input-hovered, #ebecf0); 31 | border-color: var(--ds-border-input, #dfe1e6); 32 | } 33 | 34 | highlightable-input[data-theme="atlassian"]:focus { 35 | background-color: var(--ds-background-input-pressed, #ffffff); 36 | border-color: var(--ds-border-focused, #4c9aff); 37 | } 38 | 39 | highlightable-input[data-theme="atlassian"][aria-disabled="true"] { 40 | background-color: rgba(0, 0, 0, 0.04); 41 | cursor: not-allowed; 42 | color: rgba(0, 0, 0, 0.25); 43 | } 44 | -------------------------------------------------------------------------------- /packages/highlightable-input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "highlightable-input", 3 | "description": "A simple yet fully stylable text field that highlights the text as you type.", 4 | "version": "0.4.1", 5 | "author": "Justineo ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "highlightable", 9 | "input", 10 | "highlightable-input", 11 | "highlight", 12 | "rich-text" 13 | ], 14 | "type": "module", 15 | "files": [ 16 | "dist" 17 | ], 18 | "exports": { 19 | ".": { 20 | "types": "./dist/index.d.ts", 21 | "dev": "./src/index.ts", 22 | "import": "./dist/index.js" 23 | }, 24 | "./vue": { 25 | "types": "./dist/vue.d.ts", 26 | "dev": "./src/vue.ts", 27 | "import": "./dist/vue.js" 28 | }, 29 | "./react": { 30 | "types": "./dist/react.d.ts", 31 | "dev": "./src/react.tsx", 32 | "import": "./dist/react.js" 33 | }, 34 | "./style.css": { 35 | "dev": "./src/styles/style.css", 36 | "import": "./dist/style.css" 37 | }, 38 | "./themes/*.css": { 39 | "dev": "./src/styles/themes/*.css", 40 | "import": "./dist/themes/*.css" 41 | } 42 | }, 43 | "scripts": { 44 | "build": "tsup && node ./scripts/css.mjs", 45 | "prepare": "pnpm build" 46 | }, 47 | "devDependencies": { 48 | "@types/react": "^18.3.23", 49 | "@types/react-dom": "^18.3.7", 50 | "fast-glob": "^3.3.3", 51 | "lightningcss": "^1.30.1", 52 | "react": "^18.3.1", 53 | "react-dom": "^18.3.1", 54 | "tsup": "^7.2.0", 55 | "typescript": "^5.8.3", 56 | "vue": "^3.5.16" 57 | }, 58 | "peerDependencies": { 59 | "react": ">=16.9.0", 60 | "react-dom": ">=16.9.0", 61 | "vue": "^3.2.45" 62 | }, 63 | "peerDependenciesMeta": { 64 | "vue": { 65 | "optional": true 66 | }, 67 | "react": { 68 | "optional": true 69 | }, 70 | "react-dom": { 71 | "optional": true 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/chakra.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="chakra"] { 2 | width: 100%; 3 | min-width: 0px; 4 | outline: transparent solid 2px; 5 | outline-offset: 2px; 6 | transition-property: var( 7 | --chakra-transition-property-common, 8 | background-color, 9 | border-color, 10 | color, 11 | fill, 12 | stroke, 13 | opacity, 14 | box-shadow, 15 | transform 16 | ); 17 | transition-duration: var(--chakra-transition-duration-normal, 200ms); 18 | font-size: var(--chakra-fontSizes-md, 1rem); 19 | padding-inline: var(--chakra-space-4, 1rem); 20 | padding-block: calc( 21 | ( 22 | var(--chakra-sizes-10, 2.5rem) - var(--chakra-lineHeights-short, 1.375) * 23 | 1em - 2px 24 | ) / 2 25 | ); 26 | height: var(--chakra-sizes-10, 2.5rem); 27 | line-height: var(--chakra-lineHeights-short, 1.375); 28 | border-radius: var(--chakra-radii-md, 0.375rem); 29 | border: 1px solid var(--chakra-colors-chakra-border-color, #e2e8f0); 30 | } 31 | 32 | highlightable-input[data-theme="chakra"][aria-multiline="true"] { 33 | padding-top: var(--chakra-space-2, 0.5rem); 34 | padding-bottom: var(--chakra-space-2, 0.5rem); 35 | min-height: var(--chakra-sizes-20, 5rem); 36 | vertical-align: top; 37 | } 38 | 39 | highlightable-input[data-theme="chakra"][aria-placeholder]::before { 40 | color: transparent; 41 | } 42 | 43 | highlightable-input[data-theme="chakra"][aria-placeholder]:empty::before { 44 | color: var(--chakra-colors-chakra-placeholder-color, #718096); 45 | } 46 | 47 | highlightable-input[data-theme="chakra"]:hover { 48 | border-color: var(--chakra-colors-gray-300, #cbd5e0); 49 | } 50 | 51 | highlightable-input[data-theme="chakra"]:focus { 52 | border-color: rgb(49, 130, 206); 53 | box-shadow: 0px 0px 0px 1px rgb(49, 130, 206); 54 | outline: none; 55 | } 56 | 57 | highlightable-input[data-theme="chakra"][aria-disabled="true"] { 58 | border-color: var(--chakra-colors-chakra-border-color, #e2e8f0); 59 | opacity: 0.4; 60 | cursor: not-allowed; 61 | } 62 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/carbon.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="carbon"] { 2 | background-color: var(--cds-field, #f4f4f4); 3 | border: none; 4 | border-bottom: 1px solid var(--cds-border-strong, #8d8d8d); 5 | color: var(--cds-text-primary, #161616); 6 | font-size: var(--cds-body-compact-01-font-size, 0.875rem); 7 | font-weight: var(--cds-body-compact-01-font-weight, 400); 8 | height: 2.5rem; 9 | letter-spacing: var(--cds-body-compact-01-letter-spacing, 0.16px); 10 | line-height: var(--cds-body-compact-01-line-height, 1.28572); 11 | outline: 2px solid #0000; 12 | outline-offset: -2px; 13 | padding: calc( 14 | (2.5rem - var(--cds-body-compact-01-line-height, 1.28572) * 1em - 1px) / 2 15 | ) 16 | 1rem; 17 | transition: background-color 70ms cubic-bezier(0.2, 0, 0.38, 0.9), 18 | outline 70ms cubic-bezier(0.2, 0, 0.38, 0.9); 19 | width: 100%; 20 | } 21 | 22 | highlightable-input[data-theme="carbon"][aria-multiline="true"] { 23 | height: auto; 24 | min-width: 10rem; 25 | min-height: 2.5rem; 26 | padding: 0.6875rem 1rem; 27 | line-height: var(--cds-body-01-line-height, 1.42857); 28 | resize: vertical; 29 | } 30 | 31 | highlightable-input[data-theme="carbon"][aria-placeholder]::before { 32 | color: transparent; 33 | } 34 | 35 | highlightable-input[data-theme="carbon"][aria-placeholder]:empty::before { 36 | color: var(--cds-text-placeholder, #16161666); 37 | opacity: 1; 38 | } 39 | 40 | highlightable-input[data-theme="carbon"]:focus, 41 | highlightable-input[data-theme="carbon"]:active { 42 | outline: 2px solid var(--cds-focus, #0f62fe); 43 | outline-offset: -2px; 44 | } 45 | 46 | highlightable-input[data-theme="carbon"][aria-readonly="true"] { 47 | background: transparent; 48 | } 49 | 50 | highlightable-input[data-theme="carbon"][aria-disabled="true"] { 51 | -webkit-text-fill-color: currentColor; 52 | background-color: var(--cds-field, #f4f4f4); 53 | border-bottom: 1px solid #0000; 54 | color: var(--cds-text-disabled, #16161640); 55 | cursor: not-allowed; 56 | outline: 2px solid #0000; 57 | outline-offset: -2px; 58 | } 59 | -------------------------------------------------------------------------------- /packages/site/src/react/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import HighlightableInput from 'highlightable-input/react' 3 | import { tweet } from '../rules' 4 | 5 | export default function App() { 6 | const [text, setText] = useState('Hello Mayor @Humdinger!') 7 | const [theme, setTheme] = useState('none') 8 | 9 | const [multiline, setMultiline] = useState(false) 10 | const [readonly, setReadonly] = useState(false) 11 | const [disabled, setDisabled] = useState(false) 12 | 13 | useEffect(() => { 14 | if (readonly) { 15 | setDisabled(false) 16 | } 17 | }, [readonly]) 18 | 19 | useEffect(() => { 20 | if (disabled) { 21 | setReadonly(false) 22 | } 23 | }, [disabled]) 24 | 25 | useEffect(() => { 26 | window.registerReactApp((theme) => { 27 | setTheme(theme) 28 | }) 29 | }, []) 30 | 31 | return ( 32 | <> 33 |

34 | 35 | 36 |

37 |
38 | 46 | 54 | 62 |
63 | setText(value)} 73 | /> 74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/kongponents.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme='kongponents'] { 2 | box-sizing: border-box; 3 | width: 100%; 4 | outline: none; 5 | border: 0; 6 | padding: var(--kui-space-40, 8px) var(--kui-space-50, 12px); 7 | border-radius: var(--kui-border-radius-30, 6px); 8 | box-shadow: var(--kui-shadow-border, 0px 0px 0px 1px #e0e4ea inset); 9 | background-color: var(--kui-color-background, #ffffff); 10 | color: var(--kui-color-text, #000933); 11 | font-family: var(--kui-font-family-text, Inter, Roboto, Helvetica, sans-serif); 12 | font-size: var(--kui-font-size-40, 16px); 13 | font-weight: var(--kui-font-weight-regular, 400); 14 | line-height: var(--kui-line-height-40, 24px); 15 | text-overflow: ellipsis; 16 | transition: box-shadow var(--kui-animation-duration-20, .2s) ease-in-out; 17 | } 18 | 19 | highlightable-input[data-theme='kongponents'][aria-multiline='true'] { 20 | max-height: calc(100vh - 200px); 21 | min-height: calc(var(--kui-line-height-40, 24px) * var(--rows) + var(--kui-space-40, 8px) * 2); 22 | resize: vertical; 23 | } 24 | 25 | highlightable-input[data-theme='kongponents'][aria-multiline='true'][style*='height:'] { 26 | min-height: calc(var(--kui-line-height-40, 24px) * 2 + var(--kui-space-40, 8px) * 2); 27 | } 28 | 29 | highlightable-input[data-theme='kongponents'][aria-placeholder]::before { 30 | color: transparent; 31 | } 32 | 33 | highlightable-input[data-theme='kongponents'][aria-placeholder]:empty::before { 34 | color: var(--kui-color-text-neutral, #6c7489); 35 | } 36 | 37 | highlightable-input[data-theme='kongponents']:hover { 38 | box-shadow: var(--kui-shadow-border-primary-weak, 0px 0px 0px 1px #5f9aff inset); 39 | } 40 | 41 | highlightable-input[data-theme='kongponents'][aria-readonly='true'] { 42 | background-color: var(--kui-color-background-neutral-weakest, #f9fafb); 43 | box-shadow: var(--kui-shadow-border, 0px 0px 0px 1px #e0e4ea inset); 44 | color: var(--kui-color-text-neutral-strong, #52596e); 45 | } 46 | 47 | highlightable-input[data-theme='kongponents']:focus { 48 | box-shadow: var(--kui-shadow-border-primary, 0px 0px 0px 1px #0044f4 inset), var(--kui-shadow-focus, 0px 0px 0px 4px rgba(0, 68, 244, .2)); 49 | } 50 | 51 | highlightable-input[data-theme='kongponents'][aria-disabled='true'] { 52 | background-color: var(--kui-color-background-disabled, #e0e4ea) !important; 53 | box-shadow: var(--kui-shadow-border-disabled, 0px 0px 0px 1px #e0e4ea inset) !important; 54 | color: var(--kui-color-text-disabled, #afb7c5) !important; 55 | cursor: not-allowed; 56 | } 57 | 58 | @media (min-width: 640px) { 59 | highlightable-input[data-theme='kongponents'] { 60 | font-size: var(--kui-font-size-30, 14px); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/vue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | h, 4 | ref, 5 | onMounted, 6 | watch, 7 | nextTick, 8 | type PropType, 9 | onBeforeUnmount, 10 | computed 11 | } from 'vue' 12 | import { setup, type HighlightableInput, type SetupOptions } from './index' 13 | 14 | export default defineComponent({ 15 | props: { 16 | defaultValue: String, 17 | modelValue: String, 18 | multiline: Boolean, 19 | readonly: Boolean, 20 | disabled: Boolean, 21 | rows: Number, 22 | theme: String, 23 | highlight: { 24 | type: Object as PropType, 25 | required: true 26 | }, 27 | patch: Function as PropType 28 | }, 29 | setup(props, { emit }) { 30 | const root = ref() 31 | const input = ref() 32 | const controlled = computed(() => props.modelValue !== undefined) 33 | const local = ref(props.modelValue ?? props.defaultValue) 34 | const real = computed( 35 | () => (controlled.value ? props.modelValue : local.value) || '' 36 | ) 37 | 38 | onMounted(() => { 39 | if (!root.value) { 40 | return 41 | } 42 | 43 | input.value = setup(root.value, { 44 | defaultValue: real.value, 45 | onInput({ value }) { 46 | if (value !== real.value) { 47 | emit('update:modelValue', value) 48 | 49 | if (controlled.value) { 50 | nextTick(() => { 51 | if (value !== real.value) { 52 | setValue(real.value) 53 | } 54 | }) 55 | } else { 56 | local.value = value 57 | } 58 | } 59 | }, 60 | patch: props.patch, 61 | highlight: props.highlight 62 | }) 63 | }) 64 | 65 | onBeforeUnmount(() => { 66 | input.value?.dispose() 67 | }) 68 | 69 | watch( 70 | () => [props.multiline, props.readonly, props.disabled], 71 | () => { 72 | // Should rebind after DOM change 73 | nextTick(() => { 74 | input.value?.refresh() 75 | }) 76 | } 77 | ) 78 | 79 | watch( 80 | () => real.value, 81 | (newValue) => { 82 | setValue(newValue) 83 | } 84 | ) 85 | 86 | function setValue(value: string) { 87 | input.value?.setValue(value) 88 | } 89 | 90 | return () => 91 | h('highlightable-input', { 92 | ref: root, 93 | 'aria-multiline': props.multiline || null, 94 | 'aria-readonly': props.readonly || null, 95 | 'aria-disabled': props.disabled || null, 96 | 'data-theme': props.theme || null, 97 | 'data-rows': props.rows || null 98 | }) 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/react.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useRef, 3 | useEffect, 4 | useLayoutEffect, 5 | HTMLAttributes, 6 | useState 7 | } from 'react' 8 | import { setup, type HighlightableInput, type SetupOptions } from './index' 9 | 10 | declare global { 11 | namespace JSX { 12 | interface IntrinsicElements { 13 | 'highlightable-input': any 14 | } 15 | } 16 | } 17 | 18 | type HighlightableInputProps = { 19 | defaultValue?: string 20 | value?: string 21 | multiline?: boolean 22 | readonly?: boolean 23 | disabled?: boolean 24 | rows?: number 25 | theme?: string 26 | highlight: SetupOptions['highlight'] 27 | patch?: SetupOptions['patch'] 28 | onChange?: (text: string) => void 29 | } & Omit, 'onChange'> 30 | 31 | const Component = (props: HighlightableInputProps) => { 32 | const { 33 | defaultValue, 34 | value, 35 | multiline, 36 | readonly, 37 | disabled, 38 | rows, 39 | theme, 40 | highlight, 41 | patch, 42 | onChange, 43 | ...restProps 44 | } = props 45 | 46 | const root = useRef(null) 47 | const input = useRef(null) 48 | 49 | const controlled = value !== undefined 50 | const [localValue, setLocalValue] = useState( 51 | value ?? defaultValue 52 | ) 53 | const realValue = controlled ? value : localValue 54 | const [updateSignal, setUpdateSignal] = useState(0) 55 | 56 | useLayoutEffect(() => { 57 | if (!root.current) { 58 | return 59 | } 60 | 61 | input.current = setup(root.current, { 62 | defaultValue: realValue, 63 | onInput({ value }) { 64 | if (value !== realValue) { 65 | onChange?.(value) 66 | 67 | if (controlled) { 68 | setUpdateSignal((signal) => (signal + 1) % 2) 69 | } else { 70 | setLocalValue(value) 71 | } 72 | } 73 | }, 74 | patch, 75 | highlight 76 | }) 77 | 78 | return () => { 79 | input.current?.dispose() 80 | } 81 | }, []) 82 | 83 | useLayoutEffect(() => { 84 | if (controlled) { 85 | setValue(realValue) 86 | } 87 | }, [updateSignal]) 88 | 89 | useEffect(() => { 90 | input.current?.refresh() 91 | }, [multiline, readonly, disabled]) 92 | 93 | useEffect(() => { 94 | setValue(realValue) 95 | }, [value, realValue]) 96 | 97 | function setValue(value: string = '') { 98 | input.current?.setValue(value) 99 | } 100 | 101 | return ( 102 | 111 | ) 112 | } 113 | Component.displayName = 'HighlightableInput' 114 | 115 | export default Component 116 | -------------------------------------------------------------------------------- /packages/site/src/index.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | html { 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", 9 | Helvetica, Arial, sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | font-size: 100%; 12 | scrollbar-width: thin; 13 | } 14 | 15 | body { 16 | display: flex; 17 | flex-direction: column; 18 | margin: 0; 19 | min-height: 100vh; 20 | } 21 | 22 | main { 23 | padding: 24px 64px 24px 32px; 24 | gap: 32px; 25 | flex-grow: 1; 26 | } 27 | 28 | header, 29 | footer { 30 | flex-grow: 0; 31 | overflow: hidden; 32 | background-color: var(--theme-color, transparent); 33 | color: #fff; 34 | transition: background-color 0.2s ease-in-out; 35 | } 36 | 37 | a { 38 | text-decoration: none; 39 | color: inherit; 40 | } 41 | 42 | footer { 43 | padding: 6px 24px; 44 | font-size: 12px; 45 | font-weight: 300; 46 | } 47 | 48 | footer a { 49 | font-weight: 500; 50 | } 51 | 52 | h1 { 53 | display: flex; 54 | align-items: center; 55 | margin: 0; 56 | padding: 0 32px; 57 | height: 48px; 58 | font-size: 24px; 59 | font-family: "JetBrains Mono", Menlo, monospace; 60 | } 61 | 62 | h2 { 63 | display: flex; 64 | align-items: center; 65 | gap: 8px; 66 | margin-top: 0; 67 | margin-bottom: 16px; 68 | font-size: 18px; 69 | } 70 | 71 | highlightable-input { 72 | width: 100% !important; 73 | max-width: 50vw; 74 | transition-property: all !important; 75 | } 76 | 77 | #app { 78 | display: grid; 79 | grid-template-columns: repeat(2, 400px); 80 | grid-auto-rows: auto; 81 | gap: 24px; 82 | } 83 | 84 | .row { 85 | grid-column: span 2; 86 | } 87 | 88 | #theme { 89 | position: sticky; 90 | top: 24px; 91 | float: left; 92 | margin-right: 32px; 93 | } 94 | 95 | select { 96 | border: none; 97 | outline: none; 98 | overflow: auto; 99 | scrollbar-width: thin; 100 | } 101 | 102 | select, 103 | option { 104 | font: inherit; 105 | font-size: 14px; 106 | } 107 | 108 | option { 109 | padding: 3px 12px; 110 | border-radius: 2px; 111 | } 112 | 113 | option::before { 114 | content: none; 115 | } 116 | 117 | option:checked, 118 | option:checked:focus { 119 | background-color: var(--theme-color); 120 | color: #fff; 121 | } 122 | 123 | #styler { 124 | width: 60%; 125 | min-width: 240px; 126 | font-size: 12px; 127 | font-family: "JetBrains Mono", Menlo, monospace; 128 | height: auto; 129 | resize: vertical; 130 | } 131 | 132 | .settings { 133 | display: flex; 134 | align-items: center; 135 | gap: 16px; 136 | margin-bottom: 12px; 137 | font-size: 14px; 138 | } 139 | 140 | .settings label { 141 | display: flex; 142 | align-items: center; 143 | gap: 4px; 144 | } 145 | 146 | .settings input { 147 | margin: 0; 148 | } 149 | 150 | @media (max-width: 960px) { 151 | #app { 152 | display: flex; 153 | flex-direction: column; 154 | } 155 | } 156 | 157 | @media (max-width: 600px) { 158 | #app { 159 | padding-left: 64px; 160 | } 161 | 162 | header h1 { 163 | justify-content: center; 164 | } 165 | 166 | footer { 167 | text-align: center; 168 | } 169 | 170 | highlightable-input { 171 | max-width: none; 172 | } 173 | 174 | #theme { 175 | position: sticky; 176 | top: 0; 177 | width: 100%; 178 | display: flex; 179 | justify-content: center; 180 | background-color: rgba(255, 255, 255, 0.75); 181 | backdrop-filter: blur(2px); 182 | border-bottom: 1px solid rgba(255, 255, 255, 0.75); 183 | } 184 | 185 | #theme select { 186 | margin: 12px 0; 187 | height: 32px; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/spectrum.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="spectrum"] { 2 | font-family: adobe-clean-han-simplified-c, SimSun, Heiti SC Light, 3 | -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; 4 | box-sizing: border-box; 5 | border: var( 6 | --spectrum-alias-input-border-size, 7 | var(--spectrum-global-dimension-static-size-10, 1px) 8 | ) 9 | solid; 10 | border-radius: var( 11 | --spectrum-textfield-border-radius, 12 | var( 13 | --spectrum-alias-border-radius-regular, 14 | var(--spectrum-global-dimension-size-50, 4px) 15 | ) 16 | ); 17 | padding: 4px 18 | var( 19 | --spectrum-textfield-padding-x, 20 | var(--spectrum-global-dimension-size-150, 12px) 21 | ) 22 | 4px 23 | calc( 24 | var( 25 | --spectrum-textfield-padding-x, 26 | var(--spectrum-global-dimension-size-150, 12px) 27 | ) - 1px 28 | ); 29 | text-indent: 0; 30 | width: 100%; 31 | height: var( 32 | --spectrum-textfield-height, 33 | var( 34 | --spectrum-alias-single-line-height, 35 | var(--spectrum-global-dimension-size-400, 32px) 36 | ) 37 | ); 38 | font-size: var( 39 | --spectrum-textfield-text-size, 40 | var( 41 | --spectrum-alias-font-size-default, 42 | var(--spectrum-global-dimension-font-size-100, 14px) 43 | ) 44 | ); 45 | line-height: var( 46 | --spectrum-textfield-text-line-height, 47 | var( 48 | --spectrum-alias-body-text-line-height, 49 | var(--spectrum-global-font-line-height-medium, 1.5) 50 | ) 51 | ); 52 | transition: border-color var(--spectrum-global-animation-duration-100, 130ms) 53 | ease-in-out, 54 | box-shadow var(--spectrum-global-animation-duration-100, 130ms) ease-in-out; 55 | outline: 0; 56 | background-color: var( 57 | --spectrum-textfield-background-color, 58 | var(--spectrum-global-color-gray-50, #fff) 59 | ); 60 | border-color: var( 61 | --spectrum-textfield-border-color, 62 | var(--spectrum-alias-border-color, var(--spectrum-gray-500, #909090)) 63 | ); 64 | color: var( 65 | --spectrum-textfield-text-color, 66 | var(--spectrum-alias-text-color, var(--spectrum-gray-800, #222)) 67 | ); 68 | } 69 | 70 | highlightable-input[data-theme="spectrum"][aria-multiline="true"] { 71 | height: auto; 72 | min-height: var(--spectrum-global-dimension-size-700, 56px); 73 | resize: none; 74 | overflow: auto; 75 | } 76 | 77 | highlightable-input[data-theme="spectrum"][aria-placeholder]::before { 78 | color: transparent; 79 | } 80 | 81 | highlightable-input[data-theme="spectrum"][aria-placeholder]:empty::before { 82 | color: rgba(0, 0, 0, 0.25); 83 | } 84 | 85 | highlightable-input[data-theme="spectrum"]:hover { 86 | border-color: var( 87 | --spectrum-textfield-border-color-hover, 88 | var(--spectrum-alias-border-color-hover, var(--spectrum-gray-600, #6d6d6d)) 89 | ); 90 | } 91 | 92 | highlightable-input[data-theme="spectrum"]:focus { 93 | border-color: var( 94 | --spectrum-textfield-border-color-key-focus, 95 | var(--spectrum-alias-border-color-focus, var(--spectrum-blue-800, #147af3)) 96 | ); 97 | } 98 | 99 | highlightable-input[data-theme="spectrum"][aria-disabled="true"] { 100 | background-color: var( 101 | --spectrum-textfield-background-color-disabled, 102 | var(--spectrum-global-color-gray-200, var(--spectrum-gray-200, #e6e6e6)) 103 | ); 104 | border-color: var( 105 | --spectrum-textfield-border-color-disabled, 106 | var(--spectrum-alias-border-color-transparent, transparent) 107 | ); 108 | color: var( 109 | --spectrum-textfield-text-color-disabled, 110 | var(--spectrum-alias-text-color-disabled, var(--spectrum-gray-400, #b1b1b1)) 111 | ); 112 | -webkit-text-fill-color: currentColor; 113 | } 114 | -------------------------------------------------------------------------------- /packages/site/src/index.ts: -------------------------------------------------------------------------------- 1 | import { setup } from 'highlightable-input' 2 | import { tweet, color, variable } from './rules' 3 | import mountVueApp from './vue' 4 | import mountReactApp from './react' 5 | import 'highlightable-input/style.css' 6 | import 'highlightable-input/themes/antd.css' 7 | import 'highlightable-input/themes/arco.css' 8 | import 'highlightable-input/themes/atlassian.css' 9 | import 'highlightable-input/themes/bootstrap.css' 10 | import 'highlightable-input/themes/carbon.css' 11 | import 'highlightable-input/themes/chakra.css' 12 | import 'highlightable-input/themes/fluent.css' 13 | import 'highlightable-input/themes/kongponents.css' 14 | import 'highlightable-input/themes/light.css' 15 | import 'highlightable-input/themes/lightning.css' 16 | import 'highlightable-input/themes/semi.css' 17 | import 'highlightable-input/themes/spectrum.css' 18 | 19 | declare global { 20 | interface Window { 21 | primaryColors: Record 22 | } 23 | } 24 | 25 | const els = document.querySelectorAll('highlightable-input') 26 | const styler = document.querySelector('#styler')! 27 | const customStyle = document.querySelector('#custom-style')! 28 | const prompt = document.querySelector('#prompt')! 29 | 30 | function updateStyle(value: string) { 31 | customStyle.textContent = value 32 | } 33 | 34 | updateStyle(styler.textContent!) 35 | 36 | els.forEach((el) => { 37 | setup(el, { 38 | highlight: el === styler ? color : el === prompt ? variable : tweet, 39 | onInput: ({ value }) => { 40 | console.log(value.replace(/\n/g, '↵')) 41 | 42 | if (el === styler) { 43 | updateStyle(value) 44 | } 45 | } 46 | }) 47 | }) 48 | 49 | const themeMeta = document.querySelector( 50 | 'meta[name="theme-color"]' 51 | )! 52 | const themeSelect = document.querySelector('#theme select')! 53 | themeSelect.addEventListener('change', () => { 54 | updateTheme(themeSelect.value) 55 | }) 56 | 57 | const themeUpdateCallbacks: Array<(theme: string) => void> = [] 58 | 59 | function updateTheme(theme: string) { 60 | const color = window.primaryColors[theme] || window.primaryColors.none 61 | document.documentElement.style.setProperty('--theme-color', color) 62 | themeMeta.content = color 63 | 64 | els.forEach((el) => { 65 | el.dataset.theme = theme 66 | }) 67 | 68 | themeUpdateCallbacks.forEach((callback) => { 69 | callback(theme) 70 | }) 71 | } 72 | 73 | styler.addEventListener('keydown', (e: KeyboardEvent) => { 74 | switch (e.key) { 75 | case 'Tab': 76 | e.preventDefault() 77 | document.execCommand('insertText', false, ' ') 78 | break 79 | case 'Esc': 80 | case 'Escape': 81 | styler.blur() 82 | break 83 | } 84 | }) 85 | 86 | const themeCount = themeSelect.querySelectorAll('option').length 87 | 88 | function toggleThemeSelect(collapse: boolean) { 89 | themeSelect.size = collapse ? 0 : themeCount 90 | } 91 | 92 | let mql = window.matchMedia('(max-width: 600px)') 93 | mql.addEventListener('change', (e) => { 94 | toggleThemeSelect(e.matches) 95 | }) 96 | toggleThemeSelect(mql.matches) 97 | 98 | declare global { 99 | interface Window { 100 | registerVueApp: (updater: (theme: string) => void) => void 101 | registerReactApp: (updater: (theme: string) => void) => void 102 | } 103 | } 104 | 105 | const loadingVueApp = new Promise((resolve) => { 106 | window.registerVueApp = (updateTheme) => { 107 | themeUpdateCallbacks.push(updateTheme) 108 | resolve() 109 | } 110 | }) 111 | 112 | const loadingReactApp = new Promise((resolve) => { 113 | window.registerReactApp = (updateTheme) => { 114 | themeUpdateCallbacks.push(updateTheme) 115 | resolve() 116 | } 117 | }) 118 | 119 | Promise.all([loadingVueApp, loadingReactApp]).then(() => { 120 | updateTheme(themeSelect.value) 121 | }) 122 | 123 | mountVueApp() 124 | mountReactApp() 125 | -------------------------------------------------------------------------------- /packages/site/src/rules.ts: -------------------------------------------------------------------------------- 1 | const supportLookbehind = (() => { 2 | try { 3 | new RegExp('(?<=a)b') 4 | return true 5 | } catch { 6 | return false 7 | } 8 | })() 9 | 10 | export const tweet = [ 11 | { 12 | pattern: supportLookbehind 13 | ? new RegExp('(?<=^|\\s)@[a-z][\\da-z_]+', 'gi') 14 | : /@[a-z][\\da-z_]+/gi, 15 | class: 'link' 16 | }, 17 | { 18 | pattern: supportLookbehind 19 | ? new RegExp('(?<=^|\\s)#[a-z][\\da-z_]+', 'gi') 20 | : /#[a-z][\\da-z_]+/gi, 21 | class: 'link' 22 | } 23 | ] 24 | 25 | export const variable = [ 26 | { 27 | pattern: /\{\{([a-z_]+?)\}\}/gi, 28 | replacer: (_: string, name: string) => { 29 | console.log(_, name) 30 | return `{{${name}}}` 31 | } 32 | } 33 | ] 34 | 35 | const namedColors = [ 36 | 'aliceblue', 37 | 'antiquewhite', 38 | 'aqua', 39 | 'aquamarine', 40 | 'azure', 41 | 'beige', 42 | 'bisque', 43 | 'black', 44 | 'blanchedalmond', 45 | 'blue', 46 | 'blueviolet', 47 | 'brown', 48 | 'burlywood', 49 | 'cadetblue', 50 | 'chartreuse', 51 | 'chocolate', 52 | 'coral', 53 | 'cornflowerblue', 54 | 'cornsilk', 55 | 'crimson', 56 | 'cyan', 57 | 'darkblue', 58 | 'darkcyan', 59 | 'darkgoldenrod', 60 | 'darkgray', 61 | 'darkgreen', 62 | 'darkgrey', 63 | 'darkkhaki', 64 | 'darkmagenta', 65 | 'darkolivegreen', 66 | 'darkorange', 67 | 'darkorchid', 68 | 'darkred', 69 | 'darksalmon', 70 | 'darkseagreen', 71 | 'darkslateblue', 72 | 'darkslategray', 73 | 'darkslategrey', 74 | 'darkturquoise', 75 | 'darkviolet', 76 | 'deeppink', 77 | 'deepskyblue', 78 | 'dimgray', 79 | 'dimgrey', 80 | 'dodgerblue', 81 | 'firebrick', 82 | 'floralwhite', 83 | 'forestgreen', 84 | 'fuchsia', 85 | 'gainsboro', 86 | 'ghostwhite', 87 | 'gold', 88 | 'goldenrod', 89 | 'gray', 90 | 'green', 91 | 'greenyellow', 92 | 'grey', 93 | 'honeydew', 94 | 'hotpink', 95 | 'indianred', 96 | 'indigo', 97 | 'ivory', 98 | 'khaki', 99 | 'lavender', 100 | 'lavenderblush', 101 | 'lawngreen', 102 | 'lemonchiffon', 103 | 'lightblue', 104 | 'lightcoral', 105 | 'lightcyan', 106 | 'lightgoldenrodyellow', 107 | 'lightgray', 108 | 'lightgreen', 109 | 'lightgrey', 110 | 'lightpink', 111 | 'lightsalmon', 112 | 'lightseagreen', 113 | 'lightskyblue', 114 | 'lightslategray', 115 | 'lightslategrey', 116 | 'lightsteelblue', 117 | 'lightyellow', 118 | 'lime', 119 | 'limegreen', 120 | 'linen', 121 | 'magenta', 122 | 'maroon', 123 | 'mediumaquamarine', 124 | 'mediumblue', 125 | 'mediumorchid', 126 | 'mediumpurple', 127 | 'mediumseagreen', 128 | 'mediumslateblue', 129 | 'mediumspringgreen', 130 | 'mediumturquoise', 131 | 'mediumvioletred', 132 | 'midnightblue', 133 | 'mintcream', 134 | 'mistyrose', 135 | 'moccasin', 136 | 'navajowhite', 137 | 'navy', 138 | 'oldlace', 139 | 'olive', 140 | 'olivedrab', 141 | 'orange', 142 | 'orangered', 143 | 'orchid', 144 | 'palegoldenrod', 145 | 'palegreen', 146 | 'paleturquoise', 147 | 'palevioletred', 148 | 'papayawhip', 149 | 'peachpuff', 150 | 'peru', 151 | 'pink', 152 | 'plum', 153 | 'powderblue', 154 | 'purple', 155 | 'red', 156 | 'rebeccapurple', 157 | 'rosybrown', 158 | 'royalblue', 159 | 'saddlebrown', 160 | 'salmon', 161 | 'sandybrown', 162 | 'seagreen', 163 | 'seashell', 164 | 'sienna', 165 | 'silver', 166 | 'skyblue', 167 | 'slateblue', 168 | 'slategray', 169 | 'slategrey', 170 | 'snow', 171 | 'springgreen', 172 | 'steelblue', 173 | 'tan', 174 | 'teal', 175 | 'thistle', 176 | 'tomato', 177 | 'turquoise', 178 | 'violet', 179 | 'wheat', 180 | 'white', 181 | 'whitesmoke', 182 | 'yellow', 183 | 'yellowgreen' 184 | ].sort((a, b) => b.length - a.length) 185 | const colorPattern = new RegExp( 186 | `currentColor|(?:rgba?|hsla?|hwb|lab|lch|oklch|color)\\([^)]+\\)|#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})|(?:${namedColors.join( 187 | '|' 188 | )})`, 189 | 'gi' 190 | ) 191 | export const color = [ 192 | { 193 | pattern: colorPattern, 194 | replacer: (match: string) => 195 | `${match}` 196 | } 197 | ] 198 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/cursor.ts: -------------------------------------------------------------------------------- 1 | export interface SelectOptions { 2 | force?: boolean 3 | collapse?: 'start' | 'end' | false 4 | } 5 | 6 | export type SelectOffsets = readonly [number, number] | number | true 7 | 8 | export function getSelection(el: HTMLElement): readonly [number, number] { 9 | const s = window.getSelection()! 10 | const { anchorNode, anchorOffset, focusNode, focusOffset } = s 11 | 12 | // Selecting with "Select all (⌘ + A on macOS / Ctrl + A on Windows)" 13 | // in Firefox will cause the root element to be selected. 14 | if (anchorNode === el && focusNode === el) { 15 | return [0, s.getRangeAt(0).toString().length] 16 | } 17 | 18 | const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) 19 | let start = 0 20 | let end = 0 21 | let current: Node | null = null 22 | let startOffset: number | null = null 23 | let endOffset: number | null = null 24 | 25 | while ((current = walker.nextNode())) { 26 | if (startOffset == null) { 27 | if (current === anchorNode) { 28 | start += anchorOffset 29 | startOffset = start 30 | } else { 31 | start += current.nodeValue!.length 32 | } 33 | } 34 | 35 | if (endOffset == null) { 36 | if (current === focusNode) { 37 | end += focusOffset 38 | endOffset = end 39 | } else { 40 | end += current.nodeValue!.length 41 | } 42 | } 43 | 44 | if (startOffset != null && endOffset != null) { 45 | return [startOffset, endOffset] 46 | } 47 | } 48 | 49 | return [0, 0] 50 | } 51 | 52 | export function setSelection( 53 | el: HTMLElement, 54 | offsets: SelectOffsets, 55 | { force = false, collapse = false }: SelectOptions = {} 56 | ) { 57 | if (document.activeElement !== el && !force) { 58 | return 59 | } 60 | 61 | if (offsets === true) { 62 | // select all 63 | selectAll(el) 64 | } else { 65 | selectByOffsets( 66 | el, 67 | typeof offsets === 'number' ? [offsets, offsets] : offsets 68 | ) 69 | } 70 | 71 | if (!collapse) { 72 | return 73 | } 74 | 75 | const selection = window.getSelection()! 76 | if (collapse === 'start') { 77 | selection.collapseToStart() 78 | } else if (collapse === 'end') { 79 | selection.collapseToEnd() 80 | } 81 | } 82 | 83 | function selectByOffsets(el: HTMLElement, offsets: readonly [number, number]) { 84 | const selection = window.getSelection()! 85 | const [startOffset, endOffset] = offsets 86 | const collapsed = startOffset === endOffset 87 | let [remainingStart, remainingEnd] = 88 | startOffset > endOffset 89 | ? [endOffset, startOffset] 90 | : [startOffset, endOffset] 91 | let current: Node | null = null 92 | let startNode: Node | null = null 93 | let endNode: Node | null = null 94 | 95 | const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) 96 | 97 | while ((current = walker.nextNode())) { 98 | if (startNode == null) { 99 | if (remainingStart > current.nodeValue!.length) { 100 | remainingStart -= current.nodeValue!.length 101 | } else { 102 | startNode = current 103 | } 104 | } 105 | 106 | if (endNode == null && !collapsed) { 107 | if (remainingEnd > current.nodeValue!.length) { 108 | remainingEnd -= current.nodeValue!.length 109 | } else { 110 | endNode = current 111 | } 112 | } 113 | 114 | if (startNode && (endNode || collapsed)) { 115 | const range = document.createRange() 116 | range.setStart(startNode, remainingStart) 117 | 118 | if (endNode) { 119 | range.setEnd(endNode, remainingEnd) 120 | } 121 | 122 | selection.removeAllRanges() 123 | selection.addRange(range) 124 | 125 | return 126 | } 127 | } 128 | } 129 | 130 | function selectAll(el: HTMLElement) { 131 | const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) 132 | 133 | let current: Node | null = null 134 | let first: Node | null = null 135 | let last: Node | null = null 136 | 137 | while ((current = walker.nextNode())) { 138 | if (!first) { 139 | first = current 140 | } 141 | last = current 142 | } 143 | 144 | const selection = window.getSelection()! 145 | const range = document.createRange() 146 | 147 | if (!first || !last) { 148 | range.selectNodeContents(el) 149 | return 150 | } 151 | 152 | range.setStart(first, 0) 153 | range.setEnd(last, last.nodeValue!.length) 154 | 155 | selection.removeAllRanges() 156 | selection.addRange(range) 157 | } 158 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/styles/themes/lightning.css: -------------------------------------------------------------------------------- 1 | highlightable-input[data-theme="lightning"] { 2 | font-size: 13px; 3 | padding-top: 0; 4 | padding-right: var( 5 | --slds-c-input-spacing-horizontal-end, 6 | var(--sds-c-input-spacing-horizontal-end, 1rem) 7 | ); 8 | padding-bottom: 0; 9 | padding-left: var( 10 | --slds-c-input-spacing-horizontal-start, 11 | var(--sds-c-input-spacing-horizontal-start, 0.75rem) 12 | ); 13 | width: 100%; 14 | min-height: calc(1.875rem + 2px); 15 | line-height: 1.875rem; 16 | border: 1px solid 17 | var(--slds-c-input-color-border, var(--sds-c-input-color-border, #c9c9c9)); 18 | border-radius: var( 19 | --slds-c-input-radius-border, 20 | var(--sds-c-input-radius-border, 0.25rem) 21 | ); 22 | background-color: var( 23 | --slds-c-input-color-background, 24 | var(--sds-c-input-color-background, #fff) 25 | ); 26 | color: var(--slds-c-input-text-color, var(--sds-c-input-text-color)); 27 | box-shadow: var(--slds-c-input-shadow, var(--sds-c-input-shadow)); 28 | transition: border 0.1s linear, background-color 0.1s linear; 29 | } 30 | 31 | highlightable-input[data-theme="lightning"][aria-multiline="true"] { 32 | line-height: 1.5; 33 | min-height: var( 34 | --slds-c-textarea-sizing-min-height, 35 | var(--sds-c-textarea-sizing-min-height) 36 | ); 37 | width: 100%; 38 | padding: var( 39 | --slds-c-textarea-spacing-block-start, 40 | var(--sds-c-textarea-spacing-block-start, 0.5rem) 41 | ) 42 | var( 43 | --slds-c-textarea-spacing-inline-end, 44 | var(--sds-c-textarea-spacing-inline-end, 0.75rem) 45 | ) 46 | var( 47 | --slds-c-textarea-spacing-block-end, 48 | var(--sds-c-textarea-spacing-block-end, 0.5rem) 49 | ) 50 | var( 51 | --slds-c-textarea-spacing-inline-start, 52 | var(--sds-c-textarea-spacing-inline-start, 0.75rem) 53 | ); 54 | background-color: var( 55 | --slds-c-textarea-color-background, 56 | var(--sds-c-textarea-color-background, #fff) 57 | ); 58 | color: var(--slds-c-textarea-text-color, var(--sds-c-textarea-text-color)); 59 | border: 1px solid 60 | var( 61 | --slds-c-textarea-color-border, 62 | var(--sds-c-textarea-color-border, #c9c9c9) 63 | ); 64 | border-radius: var( 65 | --slds-c-textarea-radius-border, 66 | var(--sds-c-textarea-radius-border, 0.25rem) 67 | ); 68 | box-shadow: var(--slds-c-textarea-shadow, var(--sds-c-textarea-shadow)); 69 | resize: vertical; 70 | transition: border 0.1s linear, background-color 0.1s linear; 71 | } 72 | 73 | highlightable-input[data-theme="lightning"][aria-placeholder]::before { 74 | color: transparent; 75 | } 76 | 77 | highlightable-input[data-theme="lightning"][aria-placeholder]:empty::before { 78 | color: #747474; 79 | } 80 | 81 | highlightable-input[data-theme="lightning"]:focus { 82 | --slds-c-input-color-border: var( 83 | --slds-c-input-color-border-focus, 84 | var(--sds-c-input-color-border-focus, #1b96ff) 85 | ); 86 | --slds-c-input-background-color: var( 87 | --slds-c-input-color-background-focus, 88 | var(--sds-c-input-color-background-focus, #fff) 89 | ); 90 | --slds-c-input-text-color: var( 91 | --slds-c-input-text-color-focus, 92 | var(--sds-c-input-text-color-focus) 93 | ); 94 | --slds-c-input-shadow: var( 95 | --slds-c-input-shadow-focus, 96 | var(--sds-c-input-shadow-focus, 0 0 3px #0176d3) 97 | ); 98 | outline: 0; 99 | } 100 | 101 | highlightable-input[data-theme="lightning"][aria-multiline="true"]:focus { 102 | outline: 0; 103 | color: var( 104 | --slds-c-textarea-text-color-focus, 105 | var(--sds-c-textarea-text-color-focus) 106 | ); 107 | background-color: var( 108 | --slds-c-textarea-color-background-focus, 109 | var(--sds-c-textarea-color-background-focus, #fff) 110 | ); 111 | border-color: var( 112 | --slds-c-textarea-color-border-focus, 113 | var(--sds-c-textarea-color-border-focus, #1b96ff) 114 | ); 115 | -webkit-box-shadow: var( 116 | --slds-c-textarea-shadow-focus, 117 | var(--sds-c-textarea-shadow-focus, 0 0 3px #0176d3) 118 | ); 119 | box-shadow: var( 120 | --slds-c-textarea-shadow-focus, 121 | var(--sds-c-textarea-shadow-focus, 0 0 3px #0176d3) 122 | ); 123 | } 124 | 125 | highlightable-input[data-theme="lightning"][aria-readonly="true"] { 126 | --slds-c-input-spacing-horizontal-start: 0; 127 | --slds-c-input-color-border: transparent; 128 | --slds-c-input-color-background: transparent; 129 | font-size: 0.875rem; 130 | } 131 | 132 | highlightable-input[data-theme="lightning"][aria-multiline="true"][aria-readonly="true"] { 133 | margin: 0; 134 | padding: 0; 135 | min-height: 0; 136 | line-height: normal; 137 | border: none; 138 | border-radius: 0; 139 | background: none; 140 | color: inherit; 141 | box-shadow: none; 142 | transition: none; 143 | overflow: visible; 144 | resize: none; 145 | } 146 | 147 | highlightable-input[data-theme="lightning"][aria-disabled="true"], 148 | highlightable-input[data-theme="lightning"][aria-multiline="true"][aria-disabled="true"] { 149 | background-color: #f3f3f3; 150 | border-color: #c9c9c9; 151 | color: #444; 152 | cursor: not-allowed; 153 | user-select: none; 154 | } 155 | -------------------------------------------------------------------------------- /packages/highlightable-input/src/browser.ts: -------------------------------------------------------------------------------- 1 | let registered: boolean | null = null 2 | 3 | export function registerCustomElement(): boolean { 4 | if (registered != null) { 5 | return registered 6 | } 7 | 8 | if ( 9 | typeof HTMLElement === 'undefined' || 10 | typeof customElements === 'undefined' 11 | ) { 12 | return (registered = false) 13 | } 14 | 15 | class HighlightableInput extends HTMLElement { 16 | static formAssociated = true 17 | } 18 | 19 | if (customElements.get('highlightable-input') == null) { 20 | customElements.define('highlightable-input', HighlightableInput) 21 | } 22 | 23 | return (registered = true) 24 | } 25 | 26 | let isPlainTextSupported: boolean | null = null 27 | 28 | export function supportsPlainText() { 29 | if (isPlainTextSupported == null) { 30 | isPlainTextSupported = CSS.supports( 31 | '-webkit-user-modify', 32 | 'read-write-plaintext-only' 33 | ) 34 | } 35 | 36 | return isPlainTextSupported 37 | } 38 | 39 | let isFF: boolean | null = null 40 | 41 | export function isFirefox() { 42 | if (isFF == null) { 43 | isFF = navigator.userAgent.indexOf('Firefox') !== -1 44 | } 45 | 46 | return isFF 47 | } 48 | 49 | let isCr: boolean | null = null 50 | 51 | // includes Chromium based browsers like Edge 52 | export function isChrome() { 53 | if (isCr == null) { 54 | isCr = navigator.userAgent.indexOf('Chrome') !== -1 55 | } 56 | 57 | return isCr 58 | } 59 | 60 | let isMacOS: boolean | null = null 61 | 62 | export function isMac() { 63 | if (isMacOS == null) { 64 | isMacOS = navigator.platform.indexOf('Mac') !== -1 65 | } 66 | 67 | return isMacOS 68 | } 69 | 70 | export function isMetaKey(e: KeyboardEvent) { 71 | if (isMac()) { 72 | return e.metaKey && !e.ctrlKey 73 | } 74 | 75 | return e.ctrlKey && !e.metaKey 76 | } 77 | 78 | export function isUndoShortcut(e: KeyboardEvent) { 79 | // ⌘ + Z on macOS 80 | // Ctrl + Z on windows 81 | return e.key.toUpperCase() === 'Z' && isMetaKey(e) && !e.shiftKey && !e.altKey 82 | } 83 | 84 | export function isRedoShortcut(e: KeyboardEvent) { 85 | // ⇧ + ⌘ + Z on macOS 86 | // Ctrl + Y on windows 87 | return ( 88 | (isMac() && 89 | e.key.toUpperCase() === 'Z' && 90 | isMetaKey(e) && 91 | e.shiftKey && 92 | !e.altKey) || 93 | (!isMac() && 94 | e.key.toUpperCase() === 'Y' && 95 | isMetaKey(e) && 96 | !e.shiftKey && 97 | !e.altKey) 98 | ) 99 | } 100 | 101 | export function isSelectAllShortcut(e: KeyboardEvent) { 102 | return e.key.toUpperCase() === 'A' && !e.shiftKey && !e.altKey && isMetaKey(e) 103 | } 104 | 105 | // Get the user-expected text content of the element instead of the 106 | // text content actually rendered by the browser. For single-line 107 | // inputs, line breaks are replaced with spaces. For multi-line 108 | // inputs, the last line break is removed. 109 | export function getValueFromElement(el: HTMLElement, multiLine: boolean) { 110 | // Use `innerText` instead of `textContent` to get the correct 111 | // text value. In Firefox, `textContent` is not sufficient to get 112 | // the correct line breaks while editing. 113 | const text = el.innerText || '' 114 | return multiLine ? text.replace(/\n$/, '') : text.replace(/\r?\n/g, ' ') 115 | } 116 | 117 | // The highlighted HTML need to be processed for it to be 118 | // correctly rendered in the DOM. 119 | export function getHTMLToRender(html: string, multiLine: boolean) { 120 | return !multiLine || html === '' ? html : html + '\n' 121 | } 122 | 123 | export function getScrollbarSize( 124 | exemplar: HTMLElement = document.documentElement 125 | ): { width: number; height: number } { 126 | const probe = exemplar.cloneNode(false) as HTMLElement 127 | 128 | Object.assign(probe.style, { 129 | width: '100px', 130 | height: '100px', 131 | position: 'absolute', 132 | top: '0', 133 | left: '0', 134 | overflow: 'scroll', 135 | visibility: 'hidden', 136 | pointerEvents: 'none', 137 | margin: '0', 138 | padding: '0' 139 | }) 140 | 141 | document.body.appendChild(probe) 142 | 143 | const { 144 | borderLeftWidth, 145 | borderRightWidth, 146 | borderTopWidth, 147 | borderBottomWidth 148 | } = getComputedStyle(probe) 149 | const borderLeft = Number.parseFloat(borderLeftWidth) || 0 150 | const borderRight = Number.parseFloat(borderRightWidth) || 0 151 | const borderTop = Number.parseFloat(borderTopWidth) || 0 152 | const borderBottom = Number.parseFloat(borderBottomWidth) || 0 153 | 154 | const width = probe.offsetWidth - probe.clientWidth - borderLeft - borderRight 155 | const height = 156 | probe.offsetHeight - probe.clientHeight - borderTop - borderBottom 157 | 158 | document.body.removeChild(probe) 159 | 160 | return { 161 | width: Math.max(0, width), 162 | height: Math.max(0, height) 163 | } 164 | } 165 | 166 | export function restoreResizing(e: MouseEvent) { 167 | if (isFirefox()) { 168 | return 169 | } 170 | 171 | const { target, clientX, clientY } = e 172 | const t = target as HTMLElement 173 | if (t.tagName.toLowerCase() !== 'highlightable-input') { 174 | return 175 | } 176 | 177 | const { right, bottom } = t.getBoundingClientRect() 178 | const handleSize = getScrollbarSize(t) 179 | if ( 180 | clientX > right - handleSize.width && 181 | clientY > bottom - handleSize.height 182 | ) { 183 | t.style.height = '' 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /packages/site/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <highlightable-input> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 35 | 36 | 37 |
38 |

39 | <highlightable-input> 42 |

43 |
44 |
45 | 74 |
75 |
76 |

77 | Hello, @Chase! #Shepherd 80 |
81 |
82 |

83 | 84 |

85 | Hello, @Rocky! #Mixed 92 |
93 |
94 |

95 | Hello, @Skye! #Cockapoo 102 |
103 |
104 |

105 | 106 |

107 | Hello, @Marshall! #Dalmatian 115 |
116 |
117 |

118 | Hello, @Zuma! #Labrador 124 |
125 |
126 |

127 | Hello, @Rubble! #Bulldog 133 |
134 |
135 |

136 | Hello, @Everest! #Husky 144 |
145 |
146 |

147 | Hello, @Tracker! #Chihuahua 155 |
156 |
157 |
158 |
159 |

160 | You are a professional translator. Please translate the following {{from}} text into {{target}}. If user provided {{target}} text, please translate it back to {{from}}. 166 | 167 |
168 |
169 |

170 | highlightable-input[aria-disabled] mark { 176 | opacity: 0.6; 177 | } 178 | 179 | highlightable-input .link { 180 | background-color: transparent; 181 | color: rgb(29, 155, 240); 182 | font-weight: 400; 183 | cursor: pointer; 184 | } 185 | 186 | highlightable-input .link:hover { 187 | text-decoration: underline; 188 | } 189 | 190 | highlightable-input[aria-disabled="true"] .link { 191 | pointer-events: none; 192 | } 193 | 194 | highlightable-input .variable { 195 | margin: 1px; 196 | background-color: rgba(21, 94, 239, 0.05); 197 | font-weight: 500; 198 | color: rgb(28, 100, 242); 199 | } 200 | 201 | highlightable-input .variable span { 202 | opacity: 0.6; 203 | } 204 | 205 |
206 |
207 |
208 | 215 | 216 | 217 | 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Highlightable Input

2 | 3 |

A simple yet fully stylable text field that highlights the text as you type.

4 | 5 |

Live Demo

6 | 7 | --- 8 | 9 |

Motivation

10 | 11 | There are two main approaches to implement a highlightable text field: 12 | 13 | 1. Use a `