From 85d68810a3da3ed333262db585d0eeb4d1f288f4 Mon Sep 17 00:00:00 2001 From: stagas Date: Thu, 14 Apr 2022 21:26:31 +0300 Subject: [PATCH] feature: rewrite in mixter --- .eslintrc.js | 12 +- .gitattributes | 8 +- .gitignore | 1 + .husky/{pre-commit => pre-push} | 2 +- .prettierignore | 2 - .prettierrc | 9 - .pull-configs.js | 57 ++ .swcrc | 23 + .vscode/extensions.json | 13 - .vscode/settings.json | 33 -- LICENSE | 7 + dprint.json | 30 + example/web.html | 877 ++++++++++++++++++++++++++++ playground/app.ts => example/web.ts | 28 + jest.config.js | 60 +- package.json | 119 ++-- playground/index.html | 36 -- src/buffer.ts | 78 ++- src/index.ts | 148 +---- src/textarea-code.ts | 147 +++++ {src/test => test}/buffer.spec.ts | 6 +- {src/test => test}/index.spec.ts | 45 +- textarea-code.min.js | 1 - tsconfig.cjs.json | 7 - tsconfig.dist.json | 19 +- tsconfig.esm.json | 7 - tsconfig.json | 39 +- types/globals.d.ts | 7 + types/modules.d.ts | 3 + web-test-runner.config.js | 29 +- 30 files changed, 1458 insertions(+), 395 deletions(-) rename .husky/{pre-commit => pre-push} (72%) delete mode 100644 .prettierignore delete mode 100644 .prettierrc create mode 100644 .pull-configs.js create mode 100644 .swcrc delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 dprint.json create mode 100644 example/web.html rename playground/app.ts => example/web.ts (54%) delete mode 100644 playground/index.html create mode 100644 src/textarea-code.ts rename {src/test => test}/buffer.spec.ts (99%) rename {src/test => test}/index.spec.ts (76%) delete mode 100644 textarea-code.min.js delete mode 100644 tsconfig.cjs.json delete mode 100644 tsconfig.esm.json create mode 100644 types/globals.d.ts create mode 100644 types/modules.d.ts diff --git a/.eslintrc.js b/.eslintrc.js index 7cda06e..a3147ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,12 +10,22 @@ module.exports = { ecmaVersion: 2021, sourceType: 'module', }, - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'html-jsx'], ignorePatterns: ['dist', 'node_modules'], plugins: ['import'], rules: { + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-extra-semi': 'off', + '@typescript-eslint/no-inferrable-types': 'warn', + '@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-this-alias': 'off', '@typescript-eslint/no-unused-vars': ['error', { args: 'all', argsIgnorePattern: '^_' }], + '@typescript-eslint/no-var-requires': 'off', + 'no-cond-assign': 'off', + 'no-empty': 'off', }, } diff --git a/.gitattributes b/.gitattributes index 0d6a735..fa47e8f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,8 @@ +* -linguist-detectable +bench/** linguist-detectable +src/** linguist-detectable +example/** linguist-detectable +test/** linguist-detectable +types/** linguist-detectable *.ts linguist-language=TypeScript - +*.html -linguist-detectable diff --git a/.gitignore b/.gitignore index de54113..6f2c5de 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ coverage dist node_modules package-lock.json +.swc diff --git a/.husky/pre-commit b/.husky/pre-push similarity index 72% rename from .husky/pre-commit rename to .husky/pre-push index 20d0d06..42b62e5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-push @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npm run lint +npm run prepush diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index de4d1f0..0000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -dist -node_modules diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 1240f0d..0000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "tabWidth": 2, - "useTabs": false, - "semi": false, - "singleQuote": true, - "printWidth": 150, - "trailingComma": "es5", - "arrowParens": "avoid" -} diff --git a/.pull-configs.js b/.pull-configs.js new file mode 100644 index 0000000..50f47a9 --- /dev/null +++ b/.pull-configs.js @@ -0,0 +1,57 @@ +const fs = require('fs') +const { pullConfigs } = require('pull-configs') + +const local = __dirname + '/' +const remote = 'https://github.com/stagas/typescript-minimal-template/raw/main/' + +const { assign, omit, sort, merge, replace } = pullConfigs(remote, local) + +merge('package.json', (prev, next) => { + prev.trustedDependencies ??= [] + prev.trustedDependencies = [ + ...new Set([...prev.trustedDependencies, ...(next.trustedDependencies ?? [])]), + ].sort() + prev.types = next.types + prev.scripts = next.scripts + prev.files = next.files + sort(assign(prev.devDependencies, next.devDependencies)) + + // deprecated + delete prev.devDependencies['@stagas/documentation-fork'] + delete prev.devDependencies['@rollup/plugin-commonjs'] + delete prev.devDependencies['@stagas/sucrase-jest-plugin'] + delete prev.devDependencies['@web/dev-server-esbuild'] + delete prev.devDependencies['@web/dev-server-rollup'] + delete prev.devDependencies['esbuild'] + delete prev.devDependencies['esbuild-register'] + delete prev.devDependencies['prettier'] + delete prev.devDependencies['terser'] + delete prev.devDependencies['vite-web-test-runner-plugin'] +}) +replace('.gitattributes') +replace('.gitignore') +replace('.npmrc') +replace('.eslintrc.js') +replace('.pull-configs.js') +replace('.swcrc') +replace('dprint.json') +replace('jest.config.js') +replace('tsconfig.json') +replace('tsconfig.dist.json') +replace('web-test-runner.config.js') +replace('LICENSE') + +const deprecated = [ + '.vscode/extensions.json', + '.vscode', + '.prettierrc', + '.prettierignore', + 'example/tsconfig.json', + 'vite.config.js', +] +deprecated.forEach(x => { + try { + fs.rmSync(x, { recursive: true }) + console.log('removed', x) + } catch {} +}) diff --git a/.swcrc b/.swcrc new file mode 100644 index 0000000..6a59263 --- /dev/null +++ b/.swcrc @@ -0,0 +1,23 @@ +{ + "jsc": { + "target": "es2022", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": true, + "dynamicImport": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true, + "useDefineForClassFields": true, + "react": { + "runtime": "automatic" + }, + "hidden": { + "jest": true + } + }, + "keepClassNames": true + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index aea23e8..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "recommendations": [ - "gabrielgrinberg.auto-run-command", - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint", - "usernamehw.errorlens", - "salbert.comment-ts", - "kavod-io.vscode-jest-test-adapter", - "ryanluker.vscode-coverage-gutters", - "ethansk.restore-terminals", - "gruntfuggly.todo-tree" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5d7efda..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "files.exclude": { - ".husky": true, - "coverage": true - }, - "auto-run-command.rules": [ - { - "condition": [ - "hasFile: coverage/lcov.info" - ], - "command": "coverage-gutters.watchCoverageAndVisibleEditors", - "message": "Running coverage" - } - ], - "restoreTerminals.terminals": [ - { - "splitTerminals": [ - { - "name": "coverage", - "commands": [ - "npm run cov:watch" - ] - }, - { - "name": "zsh", - "commands": [] - } - ] - }, - ], - "todo-tree.tree.scanMode": "workspace only", - "npm-scripts.showStartNotification": false -} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..924d15d --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2022 stagas + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..70dd4b1 --- /dev/null +++ b/dprint.json @@ -0,0 +1,30 @@ +{ + "indentWidth": 2, + "incremental": true, + "typescript": { + "arguments.trailingCommas": "never", + "arrowFunction.useParentheses": "preferNone", + "jsx.quoteStyle": "preferDouble", + "quoteProps": "asNeeded", + "quoteStyle": "alwaysSingle", + "semiColons": "asi", + "useBraces": "preferNone" + }, + "json": {}, + "markdown": {}, + "includes": [ + "**/*.{ts,tsx,js,jsx,cjs,mjs,json,md}" + ], + "excludes": [ + "node_modules/**/*", + "coverage/**/*", + "dist/**/*", + ".swc/**/*", + ".vscode/**/*" + ], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.64.2.wasm", + "https://plugins.dprint.dev/json-0.14.1.wasm", + "https://plugins.dprint.dev/markdown-0.12.2.wasm" + ] +} diff --git a/example/web.html b/example/web.html new file mode 100644 index 0000000..d07e0cd --- /dev/null +++ b/example/web.html @@ -0,0 +1,877 @@ + + + + + + + web + + + +
+ + + diff --git a/playground/app.ts b/example/web.ts similarity index 54% rename from playground/app.ts rename to example/web.ts index e1d6d1f..35afa71 100644 --- a/playground/app.ts +++ b/example/web.ts @@ -2,6 +2,34 @@ import { TextAreaCodeElement } from '../src' customElements.define('textarea-code', TextAreaCodeElement, { extends: 'textarea' }) +document.body.innerHTML = /*html*/ ` + + +` + const output = document.getElementById('output') as TextAreaCodeElement output.textContent = `\ diff --git a/jest.config.js b/jest.config.js index e311e7c..c7ac359 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,38 +1,42 @@ module.exports = { - testEnvironment: 'jsdom', - rootDir: 'src', + testEnvironment: 'jsdom', // 'node' + rootDir: '.', + roots: ['/test/', '/src'], testMatch: ['**/*.spec.{js,jsx,ts,tsx}'], - coverageDirectory: '../coverage', - - // enable this for real typescript builds (slow but accurate) + testPathIgnorePatterns: ['/node_modules/', '/test/web/'], + coverageDirectory: '/coverage', + collectCoverageFrom: ['src/**/*.{ts,tsx}'], + coverageProvider: 'v8', + resolver: require.resolve('@stagas/jest-node-exports-resolver'), // preset: 'ts-jest', - - // enable this for fast, correct sourcemaps but not all features supported transform: { '\\.(js|jsx|ts|tsx)$': [ - '@stagas/sucrase-jest-plugin', + '@swc-node/jest', { - jsxPragma: 'h', - jsxFragmentPragma: 'Fragment', - production: true, - disableESTransforms: true, + swc: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + tsx: true, + decorators: true, + dynamicImport: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: true, + useDefineForClassFields: true, + react: { + runtime: 'automatic', + }, + hidden: { + jest: true, + }, + }, + keepClassNames: true, + }, + }, }, ], }, - - // enable this for fast, incorrect sourcemaps but more features supported - - // transform: { - // '\\.(js|jsx|ts|tsx)$': [ - // '@swc-node/jest', - // { - // experimentalDecorators: true, - // emitDecoratorMetadata: true, - // react: { - // pragma: 'h', - // pragmaFrag: 'Fragment', - // }, - // }, - // ], - // }, } diff --git a/package.json b/package.json index f8a0732..d12a7de 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "textarea-code", "author": "stagas", "short": "stagas/textarea-code", - "description": "adds code editor behavior to a - - - diff --git a/src/buffer.ts b/src/buffer.ts index 15e8936..205d29f 100644 --- a/src/buffer.ts +++ b/src/buffer.ts @@ -1,5 +1,5 @@ import { Point, SelectionDirection } from './types' -import { startOfLine, leftmost } from './util' +import { leftmost, startOfLine } from './util' export interface Options { tabStyle: 'spaces' | 'tabs' @@ -14,7 +14,11 @@ export class Buffer { insert: (text: string) => void options: Options - constructor(textarea: HTMLTextAreaElement, insert: (text: string) => void, options: Partial = {}) { + constructor( + textarea: HTMLTextAreaElement, + insert: (text: string) => void, + options: Partial = {} + ) { this.#text = textarea this.insert = insert this.options = Object.assign({}, defaults, options) @@ -109,7 +113,11 @@ export class Buffer { this.#text.setSelectionRange(start, end, direction) } - moveCaretTo({ line, col }: Point, selection?: Point | null, direction = this.#text.selectionDirection) { + moveCaretTo( + { line, col }: Point, + selection?: Point | null, + direction = this.#text.selectionDirection + ) { const headPos = this.getPositionFromLineCol({ line, col }) const tailPos = selection ? this.getPositionFromLineCol(selection) : headPos if (selection) { @@ -117,11 +125,17 @@ export class Buffer { this.setSelectionRange(pos, pos) this.scrollIntoView() } - this.setSelectionRange(Math.min(headPos, tailPos), Math.max(headPos, tailPos), headPos < tailPos ? 'backward' : 'forward') + this.setSelectionRange( + Math.min(headPos, tailPos), + Math.max(headPos, tailPos), + headPos < tailPos ? 'backward' : 'forward' + ) this.scrollIntoView() } - replaceBlock(replacer: (text: string, startLine: number) => { diff: number; text: string; left: Point }) { + replaceBlock( + replacer: (text: string, startLine: number) => { diff: number; text: string; left: Point } + ) { const { start, end, hasSelection, selectionDirection } = this.getRange() const first = this.lineAt(start.line - 1) const last = this.lineAt(end.line - 1) @@ -138,10 +152,17 @@ export class Buffer { this.setSelectionRange(sliceStart, sliceEnd) this.insert(text) - this.scrollIntoView(this.getPositionFromLineCol({ line: end.line - +!notch, col: left.col + diff })) + this.scrollIntoView( + this.getPositionFromLineCol({ line: end.line - +!notch, col: left.col + diff }) + ) - const stillCaret = !hasSelection && start.col <= left.col + diff && end.col <= left.col + diff && first.length !== 0 - const startCol = this.lineAt(start.line - 1) === first ? start.col : stillCaret ? start.col : start.col + diff + const stillCaret = + !hasSelection && + start.col <= left.col + diff && + end.col <= left.col + diff && + first.length !== 0 + const startCol = + this.lineAt(start.line - 1) === first ? start.col : stillCaret ? start.col : start.col + diff const endCol = stillCaret ? end.col : this.lineAt(end.line - 1) === last @@ -164,12 +185,18 @@ export class Buffer { startLine ) let diff: number - if (text.trimStart().slice(0, 2) === comment) { + if (text.trimStart().slice(0, comment.length) === comment) { diff = -3 - text = text.replace(new RegExp(`^([^/]*)${comment} ?`, 'gm'), '$1') + text = text.replace(new RegExp(`^([^${comment[0]}]*)${comment} ?`, 'gm'), '$1') } else { diff = +3 - text = text.length === 0 ? comment + ' ' : text.replace(new RegExp(`^(?!$)([^/]{0,${left.col - 1}})`, 'gm'), `$1${comment} `) + text = + text.length === 0 + ? comment + ' ' + : text.replace( + new RegExp(`^(?!$)([^${comment[0]}]{0,${left.col - 1}})`, 'gm'), + `$1${comment} ` + ) } return { diff, text, left } }) @@ -181,7 +208,10 @@ export class Buffer { let slice = this.value.slice(selectionStart, selectionEnd) const c = this.options.comments const expanded = this.value.slice(selectionStart - c[1].length, selectionEnd + c[2].length) - if (expanded.indexOf(c[1]) === 0 && expanded.lastIndexOf(c[2]) === expanded.length - c[2].length) { + if ( + expanded.indexOf(c[1]) === 0 && + expanded.lastIndexOf(c[2]) === expanded.length - c[2].length + ) { slice = expanded selectionStart -= c[1].length selectionEnd += c[2].length @@ -190,7 +220,11 @@ export class Buffer { if (slice.indexOf(c[1]) === 0 && slice.lastIndexOf(c[2]) === slice.length - c[2].length) { const ins = slice.slice(c[1].length, -c[2].length) this.insert(ins) - this.setSelectionRange(selectionStart, selectionEnd - c[1].length - c[2].length, selectionDirection) + this.setSelectionRange( + selectionStart, + selectionEnd - c[1].length - c[2].length, + selectionDirection + ) } else { this.insert(c[1] + slice + c[2]) const length = c[1].length @@ -202,12 +236,13 @@ export class Buffer { indent(unindent?: boolean) { this.replaceBlock((text, startLine) => { const left = leftmost(text.split('\n'), startLine, 2) + const tabSize = this.options.tabStyle === 'tabs' ? 1 : this.options.tabSize let diff: number if (unindent) { - diff = -this.options.tabSize - text = text.replace(new RegExp(`^(\t| {1,${this.options.tabSize}})`, 'gm'), '') + diff = -tabSize + text = text.replace(new RegExp(`^(\t| {1,${tabSize}})`, 'gm'), '') } else { - diff = +this.options.tabSize + diff = +tabSize text = text.length === 0 ? this.tab : text.replace(/^[^\n]/gm, `${this.tab}$&`) } return { diff, text, left } @@ -223,7 +258,10 @@ export class Buffer { moveCaretEnd(withSelection: boolean) { const { head, tail } = this.getRange() - this.moveCaretTo({ line: head.line, col: this.lineAt(head.line - 1).length + 1 }, withSelection ? tail : null) + this.moveCaretTo( + { line: head.line, col: this.lineAt(head.line - 1).length + 1 }, + withSelection ? tail : null + ) } moveCaretLines(lines: number, withSelection: boolean) { @@ -236,7 +274,8 @@ export class Buffer { } moveLines(diff: number) { - const { start, end, hasSelection, selectionStart, selectionEnd, selectionDirection } = this.getRange() + const { start, end, hasSelection, selectionStart, selectionEnd, selectionDirection } = + this.getRange() const lines = this.value.split('\n') const notch = end.col === 1 && hasSelection ? 1 : 0 @@ -289,7 +328,8 @@ export class Buffer { } duplicate() { - const { start, end, hasSelection, selectionStart, selectionEnd, selectionDirection } = this.getRange() + const { start, end, hasSelection, selectionStart, selectionEnd, selectionDirection } = + this.getRange() const { numberOfLines } = this if (!hasSelection) { diff --git a/src/index.ts b/src/index.ts index f766c1d..9afb90d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,145 +1,3 @@ -import { Buffer } from './buffer' -import { insert, startOfLine } from './util' - -/** - * Adds code editor behavior to a ` - * ``` - */ -export class TextAreaCodeElement extends HTMLTextAreaElement { - static get observedAttributes() { - return ['tabsize', 'tabstyle', 'comments'] - } - - buffer: Buffer - pageSize = 10 - - constructor() { - super() - this.buffer = new Buffer(this, insert) - this.attachEvents() - } - - connectedCallback() { - // disable text wrapping - // TODO: these don't seem to work when being inside another custom element - this.setAttribute('wrap', 'off') - this.wrap = 'off' - this.style.whiteSpace = 'pre' - } - - updateSizes() { - const lineHeight = parseFloat(window.getComputedStyle(this).getPropertyValue('line-height')) || 16 - this.pageSize = Math.floor(this.offsetHeight / lineHeight) - 1 - } - - attachEvents() { - new ResizeObserver(() => this.updateSizes()).observe(this) - - this.addEventListener('keydown', (e: KeyboardEvent) => { - const cmdKey = e.ctrlKey || e.metaKey - - if (cmdKey) { - if ('/' === e.key) { - e.preventDefault() - this.buffer.toggleSingleComment() - return - } - if ('?' === e.key) { - e.preventDefault() - this.buffer.toggleDoubleComment() - return - } - if ('D' === e.key) { - e.preventDefault() - this.buffer.duplicate() - return - } - } - if (e.altKey || (cmdKey && e.shiftKey)) { - if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'].includes(e.key)) { - e.preventDefault() - this.buffer.moveLines( - { - ArrowUp: -1, - ArrowDown: 1, - PageUp: -this.pageSize, - PageDown: this.pageSize, - }[e.key as 'ArrowUp'] - ) - return - } - } - if (e.shiftKey && e.key === 'Delete') { - e.preventDefault() - this.buffer.deleteLine() - return - } - if (!cmdKey && !e.altKey) { - if ('Tab' === e.key) { - e.preventDefault() - const { selectionStart, selectionEnd } = this - const hasSelection = selectionStart !== selectionEnd - if (hasSelection || e.shiftKey) this.buffer.indent(e.shiftKey) - else { - this.buffer.insert(this.buffer.tab) - this.buffer.scrollIntoView() - } - return - } - - if ('Home' === e.key) { - e.preventDefault() - this.buffer.moveCaretHome(e.shiftKey) - return - } - - if ('End' === e.key) { - e.preventDefault() - this.buffer.moveCaretEnd(e.shiftKey) - return - } - - if (['PageUp', 'PageDown'].includes(e.key)) { - e.preventDefault() - this.buffer.moveCaretLines(e.key === 'PageUp' ? -this.pageSize : +this.pageSize, e.shiftKey) - return - } - } - if (!cmdKey && !e.altKey && !e.shiftKey) { - if ('Enter' === e.key) { - const { start, selectionStart } = this.buffer.getRange() - const line = this.buffer.lineAt(start.line - 1) - const indent = startOfLine(line) - if (indent > 0) { - e.preventDefault() - let ins = '\n' + line.slice(0, indent - 1) - const open = '{[(' - const match = open.indexOf(line.at(-1)!) - if (~match && start.col === line.length + 1) ins += this.buffer.tab - const pos = selectionStart + ins.length - this.buffer.insert(ins) - this.buffer.setSelectionRange(pos, pos) - this.buffer.scrollIntoView() - return - } - } - } - }) - } - - attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) { - if (name === 'tabsize') this.buffer.options.tabSize = parseInt(newValue || '') || 2 - if (name === 'tabstyle') this.buffer.options.tabStyle = newValue === 'spaces' || newValue === 'tabs' ? newValue : 'spaces' - if (name === 'comments') this.buffer.options.comments = (newValue || '// /* */').split(' ') as [string, string, string] - } -} - -export default TextAreaCodeElement +export * from './buffer' +export * from './textarea-code' +export * from './types' diff --git a/src/textarea-code.ts b/src/textarea-code.ts new file mode 100644 index 0000000..017ab1d --- /dev/null +++ b/src/textarea-code.ts @@ -0,0 +1,147 @@ +import { attrs, mixter, on, props, state } from 'mixter' +import { Buffer } from './buffer' +import { insert, startOfLine } from './util' + +export class TextAreaCodeElement extends mixter( + HTMLTextAreaElement, + attrs( + class { + tabSize = 2 + tabStyle: 'spaces' | 'tabs' = 'spaces' + comments = '// /* */' + } + ), + props( + class { + buffer?: Buffer + pageSize?: number + viewHeight?: number + lineHeight = 16 + onKeyDown?: (e: KeyboardEvent) => void + } + ), + state(({ $, effect, reduce }) => { + effect(({ host }) => { + host.style.whiteSpace = 'pre' + host.setAttribute('wrap', 'off') + host.setAttribute('spellcheck', 'false') + host.setAttribute('autocorrect', 'off') + host.setAttribute('autocomplete', 'off') + }) + + $.buffer = reduce(({ host }) => new Buffer(host, insert)) + + effect(({ buffer, comments, tabSize, tabStyle }) => { + Object.assign(buffer.options, { + tabSize, + tabStyle, + comments: comments.split(' ') as [string, string, string], + }) + }) + + $.pageSize = reduce(({ viewHeight, lineHeight }) => Math.floor(viewHeight / lineHeight) - 1) + + effect(({ host, lineHeight }) => { + const observer = new ResizeObserver(entries => { + $.viewHeight = entries[0].contentBoxSize[0].blockSize + $.lineHeight = parseFloat(window.getComputedStyle(host).getPropertyValue('line-height')) || lineHeight + }) + observer.observe(host) + return () => observer.disconnect() + }) + + $.onKeyDown = reduce(({ host, buffer, pageSize }) => (e => { + const b = buffer + const cmdKey = e.ctrlKey || e.metaKey + + if (cmdKey) { + if ('/' === e.key) { + e.preventDefault() + b.toggleSingleComment() + return + } + if ('?' === e.key) { + e.preventDefault() + b.toggleDoubleComment() + return + } + if ('D' === e.key) { + e.preventDefault() + b.duplicate() + return + } + } + if (e.altKey || (cmdKey && e.shiftKey)) { + if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'].includes(e.key)) { + e.preventDefault() + b.moveLines( + { + ArrowUp: -1, + ArrowDown: 1, + PageUp: -pageSize, + PageDown: +pageSize, + }[e.key as 'ArrowUp'] + ) + return + } + } + if (e.shiftKey && e.key === 'Delete') { + e.preventDefault() + b.deleteLine() + return + } + if (!cmdKey && !e.altKey) { + if ('Tab' === e.key) { + e.preventDefault() + const { selectionStart, selectionEnd } = host + const hasSelection = selectionStart !== selectionEnd + if (hasSelection || e.shiftKey) b.indent(e.shiftKey) + else { + b.insert(b.tab) + b.scrollIntoView() + } + return + } + + if ('Home' === e.key) { + e.preventDefault() + b.moveCaretHome(e.shiftKey) + return + } + + if ('End' === e.key) { + e.preventDefault() + b.moveCaretEnd(e.shiftKey) + return + } + + if (['PageUp', 'PageDown'].includes(e.key)) { + e.preventDefault() + b.moveCaretLines(e.key === 'PageUp' ? -pageSize : +pageSize, e.shiftKey) + return + } + } + if (!cmdKey && !e.altKey && !e.shiftKey) { + if ('Enter' === e.key) { + const { start, selectionStart } = b.getRange() + const line = b.lineAt(start.line - 1) + const indent = startOfLine(line) + if (indent > 0) { + e.preventDefault() + let ins = '\n' + line.slice(0, indent - 1) + const open = '{[(' + const match = open.indexOf(line.at(-1)!) + if (~match && start.col === line.length + 1) ins += b.tab + const pos = selectionStart + ins.length + b.insert(ins) + b.setSelectionRange(pos, pos) + b.scrollIntoView() + return + } + } + } + })) + + effect(({ host, onKeyDown }) => on()(host, 'keydown', onKeyDown)) + }) +) {} diff --git a/src/test/buffer.spec.ts b/test/buffer.spec.ts similarity index 99% rename from src/test/buffer.spec.ts rename to test/buffer.spec.ts index 0694e75..a8a5447 100644 --- a/src/test/buffer.spec.ts +++ b/test/buffer.spec.ts @@ -1,6 +1,6 @@ -import { Buffer } from '../buffer' -import { SelectionDirection } from '../types' -import { insert } from '../util' +import { Buffer } from '../src/buffer' +import { SelectionDirection } from '../src/types' +import { insert } from '../src/util' const createTextArea = () => { const textarea = document.createElement('textarea') diff --git a/src/test/index.spec.ts b/test/index.spec.ts similarity index 76% rename from src/test/index.spec.ts rename to test/index.spec.ts index b08848a..a4a91f3 100644 --- a/src/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,7 +1,7 @@ -import { TextAreaCodeElement } from '../' - // jsdom doesn't have ResizeObserver, so we polyfill it import ResizeObserverPolyfill from 'resize-observer-polyfill' +import { TextAreaCodeElement } from '../src' + window.ResizeObserver = ResizeObserverPolyfill describe('TextAreaCodeElement', () => { @@ -30,10 +30,18 @@ describe('TextAreaCodeElement', () => { textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' })) textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown' })) - textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', ctrlKey: true, shiftKey: true })) - textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', ctrlKey: true, shiftKey: true })) - textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', ctrlKey: true, shiftKey: true })) - textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', ctrlKey: true, shiftKey: true })) + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', ctrlKey: true, shiftKey: true }) + ) + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', ctrlKey: true, shiftKey: true }) + ) + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'PageUp', ctrlKey: true, shiftKey: true }) + ) + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'PageDown', ctrlKey: true, shiftKey: true }) + ) textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', shiftKey: true })) textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', shiftKey: true })) @@ -64,31 +72,36 @@ describe('TextAreaCodeElement', () => { }) it('accepts changes in attributes', () => { - const textarea = document.createElement('textarea', { is: 'textarea-code' }) as TextAreaCodeElement + const textarea = document.createElement('textarea', { + is: 'textarea-code', + }) as TextAreaCodeElement textarea.setAttribute('tabsize', '3') - expect(textarea.buffer.options.tabSize).toEqual(3) + expect(textarea.tabSize).toEqual(3) textarea.setAttribute('tabsize', '') - expect(textarea.buffer.options.tabSize).toEqual(2) + expect(textarea.tabSize).toEqual(2) textarea.setAttribute('tabstyle', '') - expect(textarea.buffer.options.tabStyle).toEqual('spaces') + expect(textarea.tabStyle).toEqual('spaces') textarea.setAttribute('tabstyle', 'spaces') - expect(textarea.buffer.options.tabStyle).toEqual('spaces') + expect(textarea.tabStyle).toEqual('spaces') textarea.setAttribute('tabstyle', 'tabs') - expect(textarea.buffer.options.tabStyle).toEqual('tabs') + expect(textarea.tabStyle).toEqual('tabs') textarea.setAttribute('tabstyle', 'other') - expect(textarea.buffer.options.tabStyle).toEqual('spaces') + expect(textarea.tabStyle).toEqual('spaces') textarea.setAttribute('comments', ';; (; ;)') - expect(textarea.buffer.options.comments).toEqual([';;', '(;', ';)']) + expect(textarea.comments).toEqual(';; (; ;)') textarea.setAttribute('comments', '') - expect(textarea.buffer.options.comments).toEqual(['//', '/*', '*/']) + expect(textarea.comments).toEqual('// /* */') }) + // web only tests if (!window.navigator.userAgent.includes('jsdom')) it('adjusts pageSize on resize', async () => { - const textarea = document.createElement('textarea', { is: 'textarea-code' }) as TextAreaCodeElement + const textarea = document.createElement('textarea', { + is: 'textarea-code', + }) as TextAreaCodeElement document.body.appendChild(textarea) textarea.style.lineHeight = '16px' textarea.style.height = '250px' diff --git a/textarea-code.min.js b/textarea-code.min.js deleted file mode 100644 index 528fc8e..0000000 --- a/textarea-code.min.js +++ /dev/null @@ -1 +0,0 @@ -var e=e=>e.match(/[^\s]|$/m).index+1,t=(t,i,n=1)=>{let s=t.reduce(((t,s,l)=>{const o=e(s);return o>=n&&ot.value=t.value.slice(0,t.selectionStart)+e+t.value.slice(t.selectionEnd):e=>document.execCommand("insertText",!1,e),n={tabStyle:"spaces",tabSize:2,comments:["//","/*","*/"]},s=class{#e;insert;options;constructor(e,t,i={}){this.#e=e,this.insert=t,this.options=Object.assign({},n,i)}get value(){return this.#e.value}get tab(){return"tabs"===this.options.tabStyle?"\t":" ".repeat(this.options.tabSize)}get numberOfLines(){return~this.value.indexOf("\n")&&this.value.split("\n").length||1}static lineAt(e,t){return e.split(/\n/).at(t)}lineAt(e){return s.lineAt(this.value,e)}scrollIntoView(e){const{selectionStart:t,selectionEnd:i,selectionDirection:n}=this.#e;null==e&&(e="forward"===n?i:t),this.#e.setSelectionRange(e,e),this.#e.blur(),this.#e.focus(),this.#e.setSelectionRange(t,i,n)}getRange(){const{selectionStart:e,selectionEnd:t,selectionDirection:i}=this.#e,[n,s]=[e,t].map(this.getLineCol),[l,o]="forward"===i?[s,n]:[n,s];return{start:n,end:s,head:l,tail:o,hasSelection:e!==t,selectionStart:e,selectionEnd:t,selectionDirection:i}}getLineCol=e=>s.getLineCol(this.value,e);static getLineCol(e,t){let i=1,n=1;for(let s=0;ss.length)return e.length;if(i<1)return 0;const l=s.slice(0,i);return l[l.length-1]=l.at(-1).slice(0,Math.max(1,n)-1),l.join("\n").length}getArea({start:e,end:t}){return[this.getPositionFromLineCol(e),this.getPositionFromLineCol(t)]}setSelectionRange(e,t,i){e=Math.max(0,e),t=Math.min(this.value.length,t),this.#e.setSelectionRange(e,t,i)}moveCaretTo({line:e,col:t},i,n=this.#e.selectionDirection){const s=this.getPositionFromLineCol({line:e,col:t}),l=i?this.getPositionFromLineCol(i):s;if(i){const e=Math["backward"===n?"min":"max"](s,l);this.setSelectionRange(e,e),this.scrollIntoView()}this.setSelectionRange(Math.min(s,l),Math.max(s,l),s1||!n?1:0,c=this.getPositionFromLineCol({line:t.line,col:1}),a=this.getPositionFromLineCol({line:i.line+r,col:1}),h=this.value.slice(c,a),{diff:g,text:f,left:u}=e(h,t.line);if(f.length===a-c)return;this.setSelectionRange(c,a),this.insert(f),this.scrollIntoView(this.getPositionFromLineCol({line:i.line-+!r,col:u.col+g}));const m=!n&&t.col<=u.col+g&&i.col<=u.col+g&&0!==l.length,d=this.lineAt(t.line-1)===l||m?t.col:t.col+g,p=m||this.lineAt(i.line-1)===o?i.col:0===h.length?i.col+g:Math.max(1+(t.line!==i.line?r:0),i.col+r*g);this.setSelectionRange(this.getPositionFromLineCol({line:t.line,col:d}),this.getPositionFromLineCol({line:i.line,col:p}),s)}toggleSingleComment(){const e=this.options.comments[0];this.replaceBlock(((i,n)=>{const s=t(i.split("\n").filter((e=>e.length)),n);let l;return i.trimStart().slice(0,2)===e?(l=-3,i=i.replace(new RegExp(`^([^/]*)${e} ?`,"gm"),"$1")):(l=3,i=0===i.length?e+" ":i.replace(new RegExp(`^(?!$)([^/]{0,${s.col-1}})`,"gm"),`$1${e} `)),{diff:l,text:i,left:s}}))}toggleDoubleComment(){let{selectionStart:e,selectionEnd:t,selectionDirection:i}=this.getRange(),n=this.value.slice(e,t);const s=this.options.comments,l=this.value.slice(e-s[1].length,t+s[2].length);if(0===l.indexOf(s[1])&&l.lastIndexOf(s[2])===l.length-s[2].length&&(n=l,e-=s[1].length,t+=s[2].length,this.setSelectionRange(e,t,i)),0===n.indexOf(s[1])&&n.lastIndexOf(s[2])===n.length-s[2].length){const l=n.slice(s[1].length,-s[2].length);this.insert(l),this.setSelectionRange(e,t-s[1].length-s[2].length,i)}else{this.insert(s[1]+n+s[2]);const l=s[1].length;this.setSelectionRange(e+l,t+l,i)}this.scrollIntoView()}indent(e){this.replaceBlock(((i,n)=>{const s=t(i.split("\n"),n,2);let l;return e?(l=-this.options.tabSize,i=i.replace(new RegExp(`^(\t| {1,${this.options.tabSize}})`,"gm"),"")):(l=+this.options.tabSize,i=0===i.length?this.tab:i.replace(/^[^\n]/gm,`${this.tab}$&`)),{diff:l,text:i,left:s}}))}moveCaretHome(t){const{head:i,tail:n}=this.getRange();let s=e(this.lineAt(i.line-1));i.col===s&&(s=1),this.moveCaretTo({line:i.line,col:s},t?n:null)}moveCaretEnd(e){const{head:t,tail:i}=this.getRange();this.moveCaretTo({line:t.line,col:this.lineAt(t.line-1).length+1},e?i:null)}moveCaretLines(e,t){const{head:i,tail:n}=this.getRange(),{numberOfLines:s}=this;let l=i.line+e;l<1&&i.line>1?l=1:l>s&&i.line0;let h=t.line+(a?0:e),g=i.line+(a?e:0)-c;if(a){const t=g-r.length;t>0&&(g-=t,e-=t)}else{const t=1-h;t>0&&(h+=t,e+=t)}if(!e)return;const f={start:{line:h,col:1},end:{line:g,col:this.lineAt(g-1).length+1}},[u,m]=this.getArea(f),d=this.value.slice(u,m).split("\n"),p=a?i.line-c-t.line+1:-e,b=d.slice(0,p),v=d.slice(p);this.setSelectionRange(u,m,o),this.insert(v.concat(b).join("\n"));const S=a?v.join("\n").length+1:-(b.join("\n").length+1),C=s+S,x=l+S;this.setSelectionRange(C,x,o),this.scrollIntoView(a?x:C)}duplicate(){const{start:e,end:t,hasSelection:i,selectionStart:n,selectionEnd:s,selectionDirection:l}=this.getRange(),{numberOfLines:o}=this;i||(e.col=1,t.col=1,t.line++);const[r,c]=this.getArea({start:e,end:t});let a=this.value.slice(r,c);i||"\n"===a.at(-1)||(a=o>1?this.value.slice(r-1,c):"\n"+a),this.setSelectionRange(c,c),this.insert(a);const h=a.length;i?this.setSelectionRange(c,c+h,l):this.setSelectionRange(n+h,s+h),this.scrollIntoView()}deleteLine(){const{start:e,end:t,hasSelection:i}=this.getRange(),{numberOfLines:n}=this,s={line:e.line,col:e.col};i||(t.linethis.updateSizes())).observe(this),this.addEventListener("keydown",(t=>{const i=t.ctrlKey||t.metaKey;if(document.activeElement===this){if(i){if("/"===t.key)return t.preventDefault(),void this.buffer.toggleSingleComment();if("?"===t.key)return t.preventDefault(),void this.buffer.toggleDoubleComment();if("D"===t.key)return t.preventDefault(),void this.buffer.duplicate()}if((t.altKey||i&&t.shiftKey)&&["ArrowUp","ArrowDown","PageUp","PageDown"].includes(t.key))return t.preventDefault(),void this.buffer.moveLines({ArrowUp:-1,ArrowDown:1,PageUp:-this.pageSize,PageDown:this.pageSize}[t.key]);if(t.shiftKey&&"Delete"===t.key)return t.preventDefault(),void this.buffer.deleteLine();if(!i&&!t.altKey){if("Tab"===t.key){t.preventDefault();const{selectionStart:e,selectionEnd:i}=this;return void(e!==i||t.shiftKey?this.buffer.indent(t.shiftKey):(this.buffer.insert(this.buffer.tab),this.buffer.scrollIntoView()))}if("Home"===t.key)return t.preventDefault(),void this.buffer.moveCaretHome(t.shiftKey);if("End"===t.key)return t.preventDefault(),void this.buffer.moveCaretEnd(t.shiftKey);if(["PageUp","PageDown"].includes(t.key))return t.preventDefault(),void this.buffer.moveCaretLines("PageUp"===t.key?-this.pageSize:+this.pageSize,t.shiftKey)}if(!i&&!t.altKey&&!t.shiftKey&&"Enter"===t.key){const{start:i,selectionStart:n}=this.buffer.getRange(),s=this.buffer.lineAt(i.line-1),l=e(s);if(l>0){t.preventDefault();let e="\n"+s.slice(0,l-1);~"{[(".indexOf(s.at(-1))&&i.col===s.length+1&&(e+=this.buffer.tab);const o=n+e.length;return this.buffer.insert(e),this.buffer.setSelectionRange(o,o),void this.buffer.scrollIntoView()}}}}))}attributeChangedCallback(e,t,i){"tabsize"===e&&(this.buffer.options.tabSize=parseInt(i||"")||2),"tabstyle"===e&&(this.buffer.options.tabStyle="spaces"===i||"tabs"===i?i:"spaces"),"comments"===e&&(this.buffer.options.comments=(i||"// /* */").split(" "))}},o=l;export{l as TextAreaCodeElement,o as default}; \ No newline at end of file diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json deleted file mode 100644 index cc375d5..0000000 --- a/tsconfig.cjs.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.dist.json", - "compilerOptions": { - "outDir": "dist/cjs", - "module": "commonjs" - } -} diff --git a/tsconfig.dist.json b/tsconfig.dist.json index 0ffaee8..64bdd88 100644 --- a/tsconfig.dist.json +++ b/tsconfig.dist.json @@ -1,14 +1,21 @@ { "extends": "./tsconfig.json", + "include": [ + "src" + ], "exclude": [ - "playground", - "benchmark", + "dist", "example", - "**/*.spec.ts", - "**/*.spec.tsx" + "node_modules", + "test", + "types" ], "compilerOptions": { - "isolatedModules": true, - "inlineSources": true + "jsx": "react-jsx", + "types": [ + "./types/globals", + "./types/modules", + "node" + ] } } diff --git a/tsconfig.esm.json b/tsconfig.esm.json deleted file mode 100644 index 0cc598c..0000000 --- a/tsconfig.esm.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.dist.json", - "compilerOptions": { - "outDir": "dist/esm", - "module": "esnext" - } -} diff --git a/tsconfig.json b/tsconfig.json index 532f742..b6ec9ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,46 @@ { "extends": "@tsconfig/node16/tsconfig.json", - "include": [ - "playground", - "benchmark", - "example", - "src" - ], + "watchOptions": { + "watchFile": "priorityPollingInterval", + "watchDirectory": "dynamicPriorityPolling" + }, "ts-node": { "files": true }, + "include": [ + "example", + "src", + "test", + "types" + ], + "exclude": [ + "node_modules", + "dist" + ], "compilerOptions": { + "outDir": "dist/cjs", "lib": [ - "dom" + "DOM", + "DOM.Iterable", + "ESNext" ], - "outDir": "dist", + "emitDeclarationOnly": true, "moduleResolution": "node", + "useDefineForClassFields": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "isolatedModules": true, + "inlineSources": true, "declaration": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, "sourceMap": true, + "allowJs": true, + "incremental": true, "module": "commonjs", "target": "esnext", - "jsx": "react", - "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", + "jsx": "preserve", "strict": true } } diff --git a/types/globals.d.ts b/types/globals.d.ts new file mode 100644 index 0000000..53662e1 --- /dev/null +++ b/types/globals.d.ts @@ -0,0 +1,7 @@ +/* when needed to enable jsx */ + +// declare namespace JSX { +// declare interface IntrinsicElements { +// [k: string]: any +// } +// } diff --git a/types/modules.d.ts b/types/modules.d.ts new file mode 100644 index 0000000..f5fda9c --- /dev/null +++ b/types/modules.d.ts @@ -0,0 +1,3 @@ +/* ambient types like: */ + +// declare module 'scoped-registries' diff --git a/web-test-runner.config.js b/web-test-runner.config.js index 8e1b98e..a0f28aa 100644 --- a/web-test-runner.config.js +++ b/web-test-runner.config.js @@ -1,17 +1,24 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { esbuildPlugin } = require('@web/dev-server-esbuild') +const { chromeLauncher, summaryReporter } = require('@web/test-runner') +const { vite } = require('wtr-plugin-vite') module.exports = { + concurrency: 1, nodeResolve: true, - files: ['src/**/*.spec.{ts,tsx}'], - plugins: [ - esbuildPlugin({ - ts: true, - tsx: true, - jsxFactory: 'h', - jsxFragment: 'Fragment', - }), - ], + files: ['test/**/*.spec.web.{ts,tsx}'], + plugins: [vite()], + browsers: [chromeLauncher({ + launchOptions: { + args: [ + '--allow-insecure-localhost', + '--autoplay-policy=no-user-gesture-required', + '--ignore-certificate-errors', + '--mute-audio', + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + ], + }, + })], + reporters: [summaryReporter()], coverageConfig: { include: ['src/**/*.{ts,tsx}'], },