├── .gitignore ├── .storybook ├── main.js ├── preview.css └── preview.js ├── LICENSE ├── README.md ├── package.json ├── src ├── compiler │ ├── highlighter.ts │ ├── index.ts │ ├── lexer.ts │ ├── parser.ts │ └── types.ts ├── components │ ├── code-input.tsx │ ├── hints.tsx │ └── styles.ts └── index.ts ├── stories └── code-input.stories.tsx ├── test ├── lexer.test.ts └── parser.test.ts ├── tsconfig.json ├── website ├── .env.local ├── package.json ├── public │ └── index.html ├── src │ ├── Demo.tsx │ ├── Header.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ └── styles.css ├── tsconfig.json └── yarn.lock └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../stories/**/*.stories.@(ts|tsx|js|jsx)"], 3 | addons: ["@storybook/addon-links", "@storybook/addon-essentials"], 4 | typescript: { 5 | check: true, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.storybook/preview.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | input::placeholder { 6 | color: #999; 7 | } 8 | 9 | .input { 10 | display: block; 11 | width: 100%; 12 | padding: 12px; 13 | font-size: 15px; 14 | font-family: monospace; 15 | line-height: 19px; 16 | letter-spacing: normal; 17 | border: 1px solid #ddd; 18 | border-radius: 5px; 19 | background: #eee; 20 | transition: 0.2s box-shadow ease; 21 | } 22 | 23 | .input:focus { 24 | outline: 2px solid transparent; 25 | border: 1px solid rgba(66, 153, 225, 0.45); 26 | box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); 27 | } 28 | 29 | .input:invalid { 30 | border: 1px solid rgba(255, 20, 20, 0.36); 31 | } 32 | 33 | .input:invalid:focus { 34 | box-shadow: 0 0 0 3px rgba(255, 0, 0, 0.08); 35 | } 36 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import "./preview.css"; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: "^on.*" }, 5 | }; 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Daniel Grant 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-code-input 2 | 3 | A lightweight component that turns `` into a mini code editor. 4 | 5 | Provides basic tokenisation, parsing, syntax highlighting, validation and code completion for simple code expressions. 6 | 7 | There are zero dependencies and you can style the input in any way that you want. 8 | 9 | [View examples →](http://react-code-input.netlify.app) 10 | 11 | ## Quick start 12 | 13 | ```tsx 14 | import { CodeInput } from "@djgrant/react-code-input"; 15 | 16 | export default () => ( 17 | { 23 | console.log(event.tokens); 24 | console.log(event.currentTarget.value); 25 | }} 26 | /> 27 | ); 28 | ``` 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.8.2", 3 | "name": "@djgrant/react-code-input", 4 | "author": "Daniel Grant", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "module": "dist/react-code-input.esm.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "src" 12 | ], 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why", 21 | "storybook": "start-storybook -p 6006", 22 | "build-storybook": "build-storybook" 23 | }, 24 | "husky": { 25 | "hooks": { 26 | "pre-commit": "tsdx lint" 27 | } 28 | }, 29 | "peerDependencies": { 30 | "react": ">=16" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.12.1", 34 | "@size-limit/preset-small-lib": "^4.6.0", 35 | "@storybook/addon-essentials": "^6.0.26", 36 | "@storybook/addon-info": "^5.3.21", 37 | "@storybook/addon-links": "^6.0.26", 38 | "@storybook/addons": "^6.0.26", 39 | "@storybook/react": "^6.0.26", 40 | "@types/react": "^16.9.52", 41 | "@types/react-dom": "^16.9.8", 42 | "babel-loader": "^8.1.0", 43 | "husky": "^4.3.0", 44 | "react": "^16.14.0", 45 | "react-dom": "^16.14.0", 46 | "react-is": "^16.13.1", 47 | "size-limit": "^4.6.0", 48 | "tsdx": "^0.14.1", 49 | "tslib": "^2.0.3", 50 | "typescript": "^4.0.3" 51 | }, 52 | "size-limit": [ 53 | { 54 | "path": "dist/react-code-input.cjs.production.min.js", 55 | "limit": "2 KB" 56 | }, 57 | { 58 | "path": "dist/react-code-input.esm.js", 59 | "limit": "2 KB" 60 | } 61 | ], 62 | "prettier": { 63 | "printWidth": 80, 64 | "semi": true, 65 | "singleQuote": false, 66 | "trailingComma": "es5" 67 | }, 68 | "engines": { 69 | "node": ">=10" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/compiler/highlighter.ts: -------------------------------------------------------------------------------- 1 | import { EditorToken, Token } from "./types"; 2 | 3 | export const getEditorTokens = ( 4 | tokens: Token[], 5 | symbols: string[] 6 | ): EditorToken[] => { 7 | return tokens.map((token, i) => { 8 | const nextToken = getNextToken(i); 9 | switch (token.type) { 10 | case "identifier": 11 | const matchingTokens = symbols.filter( 12 | v => 13 | v.length >= token.value.length && 14 | v.startsWith(token.value) && 15 | v !== token.value 16 | ); 17 | 18 | const variant = 19 | nextToken?.type === "leftParen" ? "CallExpression" : undefined; 20 | 21 | return { 22 | ...token, 23 | variant, 24 | hints: matchingTokens, 25 | valid: symbols.includes(token.value), 26 | }; 27 | 28 | case "number": 29 | return { 30 | ...token, 31 | valid: token.value.split("").filter(char => char === ".").length < 2, 32 | }; 33 | 34 | case "unknown": 35 | return { ...token, valid: false }; 36 | 37 | default: 38 | return { ...token, valid: true }; 39 | } 40 | }); 41 | 42 | function getNextToken(i: number): Token | null { 43 | const token = tokens[++i]; 44 | if (!token) return null; 45 | if (token.type === "whitespace") return getNextToken(i); 46 | return token; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/compiler/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lexer"; 2 | export * from "./highlighter"; 3 | export * from "./parser"; 4 | export * from "./types"; 5 | -------------------------------------------------------------------------------- /src/compiler/lexer.ts: -------------------------------------------------------------------------------- 1 | import { Token } from "./types"; 2 | 3 | export const NUMBER = /[0-9.]/; 4 | export const IDENTIFIER = /[a-z0-9_]/i; 5 | export const WHITESPACE = /\s/; 6 | export const QUOTE_MARK = /['"]/; 7 | export const OPERATORS = ["+", "-", "/", "*"]; 8 | 9 | export const getTokens = (code: string) => { 10 | const tokens: Token[] = []; 11 | let index = 0; 12 | 13 | while (index < code.length) { 14 | const start = index; 15 | let char = code[index]; 16 | 17 | if (char === "(") { 18 | tokens.push({ 19 | type: "leftParen", 20 | value: char, 21 | start, 22 | end: index + 1, 23 | }); 24 | index++; 25 | continue; 26 | } 27 | if (char === ")") { 28 | tokens.push({ 29 | type: "rightParen", 30 | value: char, 31 | start, 32 | end: index + 1, 33 | }); 34 | index++; 35 | continue; 36 | } 37 | if (char === "[") { 38 | tokens.push({ 39 | type: "leftSquare", 40 | value: char, 41 | start, 42 | end: index + 1, 43 | }); 44 | index++; 45 | continue; 46 | } 47 | if (char === "]") { 48 | tokens.push({ 49 | type: "rightSquare", 50 | value: char, 51 | start, 52 | end: index + 1, 53 | }); 54 | index++; 55 | continue; 56 | } 57 | if (char === ",") { 58 | tokens.push({ 59 | type: "comma", 60 | value: char, 61 | start, 62 | end: index + 1, 63 | }); 64 | index++; 65 | continue; 66 | } 67 | if (OPERATORS.includes(char)) { 68 | let seq = ""; 69 | while (OPERATORS.includes(char)) { 70 | seq += char; 71 | char = code[++index]; 72 | continue; 73 | } 74 | tokens.push({ 75 | type: "operator", 76 | value: seq, 77 | start, 78 | end: index, 79 | }); 80 | continue; 81 | } 82 | if (NUMBER.test(char)) { 83 | let seq = ""; 84 | while (NUMBER.test(char)) { 85 | seq += char; 86 | char = code[++index]; 87 | } 88 | tokens.push({ 89 | type: "number", 90 | value: seq, 91 | start, 92 | end: index, 93 | }); 94 | continue; 95 | } 96 | if (QUOTE_MARK.test(char)) { 97 | const quoteMark = char; 98 | let seq = quoteMark; 99 | char = code[++index]; 100 | while (char && char !== quoteMark) { 101 | seq += char; 102 | char = code[++index]; 103 | } 104 | if (char && char === quoteMark) { 105 | seq += char; 106 | index += 1; 107 | tokens.push({ 108 | type: "string", 109 | raw: seq, 110 | value: seq.slice(1, -1), 111 | start, 112 | end: index, 113 | }); 114 | } else { 115 | tokens.push({ 116 | type: "unknown", 117 | value: seq, 118 | start, 119 | end: index, 120 | }); 121 | } 122 | continue; 123 | } 124 | if (IDENTIFIER.test(char)) { 125 | let seq = ""; 126 | while (char && IDENTIFIER.test(char)) { 127 | seq += char; 128 | char = code[++index]; 129 | } 130 | tokens.push({ 131 | type: ["true", "false"].includes(seq) ? "boolean" : "identifier", 132 | value: seq, 133 | start, 134 | end: index, 135 | }); 136 | continue; 137 | } 138 | if (WHITESPACE.test(char)) { 139 | tokens.push({ 140 | type: "whitespace", 141 | raw: "\u00A0", 142 | value: char, 143 | start, 144 | end: index + 1, 145 | }); 146 | index++; 147 | continue; 148 | } 149 | 150 | tokens.push({ 151 | type: "unknown", 152 | value: char, 153 | start, 154 | end: index + 1, 155 | }); 156 | index++; 157 | } 158 | 159 | return tokens; 160 | }; 161 | -------------------------------------------------------------------------------- /src/compiler/parser.ts: -------------------------------------------------------------------------------- 1 | import { AST, Token } from "./types"; 2 | 3 | const ADDSUB = /^\+$|^\-$/; 4 | const MULDIV = /^\*$|^\/$/; 5 | 6 | type Cursor = Token & { next: Token | null }; 7 | 8 | export const buildAST = (allTokens: Token[]): AST | null => { 9 | const tokens = allTokens.filter(token => token.type !== "whitespace"); 10 | let i = -1; 11 | let token = getNextToken(); 12 | 13 | if (tokens.length === 0) return null; 14 | 15 | const ast = parseExpression(); 16 | 17 | if (token) { 18 | throw new UnexpectedTokenError(allTokens, token); 19 | } 20 | 21 | return ast; 22 | 23 | function getNextToken(): Cursor | null { 24 | i++; 25 | const last = tokens.length - 1; 26 | const next = i < last ? tokens[i + 1] : null; 27 | return i <= last ? { ...tokens[i], next } : null; 28 | } 29 | 30 | function parseTerminal(): AST { 31 | if (!token) { 32 | throw new EndOfLineError(allTokens); 33 | } 34 | 35 | if (token.type === "number") { 36 | const node: AST = { 37 | type: "Literal", 38 | value: Number(token.value), 39 | raw: token.value, 40 | start: token.start, 41 | end: token.end, 42 | }; 43 | token = getNextToken(); 44 | return node; 45 | } 46 | 47 | if (token.type === "string") { 48 | const node: AST = { 49 | type: "Literal", 50 | value: token.value, 51 | raw: token.value, 52 | start: token.start, 53 | end: token.end, 54 | }; 55 | token = getNextToken(); 56 | return node; 57 | } 58 | 59 | if (token.type === "boolean") { 60 | const node: AST = { 61 | type: "Literal", 62 | value: token.value === "true" ? true : false, 63 | raw: token.value, 64 | start: token.start, 65 | end: token.end, 66 | }; 67 | token = getNextToken(); 68 | return node; 69 | } 70 | 71 | if (token.type === "identifier") { 72 | const node: AST = { 73 | type: "Identifier", 74 | name: token.value, 75 | start: token.start, 76 | end: token.end, 77 | }; 78 | token = getNextToken(); 79 | return node; 80 | } 81 | 82 | throw new UnknownTokenError(allTokens, token); 83 | } 84 | 85 | function parseExpression(): AST { 86 | let node: AST | void; 87 | 88 | // Sub Expression 89 | if (token?.type === "leftParen") { 90 | token = getNextToken(); 91 | node = parseExpression(); 92 | if (!token) { 93 | throw new EndOfLineError(allTokens, ")"); 94 | } else if (token?.type !== "rightParen") { 95 | throw new UnexpectedTokenError(allTokens, token); 96 | } else { 97 | token = getNextToken(); 98 | } 99 | } 100 | 101 | // Array Expression 102 | if (token?.type === "leftSquare") { 103 | node = { 104 | type: "ArrayExpression", 105 | elements: [], 106 | start: token.start, 107 | end: -1, 108 | }; 109 | 110 | token = getNextToken(); 111 | while (token && token?.type !== "rightSquare") { 112 | if (token.type === "comma") { 113 | token = getNextToken(); 114 | } else { 115 | node.elements.push(parseExpression()); 116 | } 117 | } 118 | if (!token) throw new EndOfLineError(allTokens, "]"); 119 | token = getNextToken(); 120 | node.end = i; 121 | } 122 | 123 | if (!node) { 124 | node = parseTerminal(); 125 | } 126 | 127 | // Call Expression 128 | if (node.type === "Identifier" && token?.type === "leftParen") { 129 | node = { 130 | type: "CallExpression", 131 | callee: node, 132 | arguments: [], 133 | start: node.start, 134 | end: -1, 135 | }; 136 | token = getNextToken(); 137 | while (token && token.type !== "rightParen") { 138 | if (token.type === "comma") { 139 | token = getNextToken(); 140 | } else { 141 | node.arguments.push(parseExpression()); 142 | if (token && !["comma", "rightParen"].includes(token.type)) { 143 | throw new UnexpectedTokenError(allTokens, token); 144 | } 145 | } 146 | } 147 | if (!token) throw new EndOfLineError(allTokens, ")"); 148 | node.end = token.end; 149 | token = getNextToken(); 150 | } 151 | 152 | // Binary Expression 153 | while (token?.type === "operator") { 154 | const start: number = node.start; 155 | const operator = token.value; 156 | const left: AST = node; 157 | token = getNextToken(); 158 | const next = token?.next; 159 | const nextOperator = next?.type === "operator" ? next.value : null; 160 | 161 | const rightTerminalRules = [ 162 | // E = T +- T +- E 163 | ADDSUB.test(operator) && nextOperator && ADDSUB.test(nextOperator), 164 | // E = T */ T */+- E 165 | MULDIV.test(operator) && nextOperator, 166 | ]; 167 | 168 | const right = rightTerminalRules.some(Boolean) 169 | ? parseTerminal() 170 | : parseExpression(); 171 | 172 | const end = right.end; 173 | 174 | node = { 175 | type: "BinaryExpression", 176 | operator, 177 | left, 178 | right, 179 | start, 180 | end, 181 | }; 182 | } 183 | 184 | return node; 185 | } 186 | }; 187 | 188 | export class ParseError extends Error { 189 | constructor(allTokens: Token[], token: Token | null, message: string) { 190 | super(); 191 | const padEnd = 192 | (token && token.start + 1) || allTokens[allTokens.length - 1].end; 193 | const originalCode = allTokens.map(t => t.raw || t.value).join(""); 194 | const leftPadding = Array.from(new Array(padEnd)).join(" "); 195 | this.message = `${message}\n\n${originalCode}\n${leftPadding}^`; 196 | Object.setPrototypeOf(this, ParseError.prototype); 197 | } 198 | } 199 | 200 | export class UnknownTokenError extends ParseError { 201 | constructor(allTokens: Token[], token: Token) { 202 | super(allTokens, token, `Invalid token ${token.value}`); 203 | Object.setPrototypeOf(this, UnknownTokenError.prototype); 204 | } 205 | } 206 | 207 | export class UnexpectedTokenError extends ParseError { 208 | constructor(allTokens: Token[], token: Token) { 209 | super(allTokens, token, `Unexpected token ${token.value}`); 210 | Object.setPrototypeOf(this, UnexpectedTokenError.prototype); 211 | } 212 | } 213 | 214 | export class EndOfLineError extends ParseError { 215 | constructor(allTokens: Token[], expectedToken?: string) { 216 | const message = expectedToken 217 | ? `Expected ${expectedToken}` 218 | : `Unexpected end of line`; 219 | super(allTokens, null, message); 220 | Object.setPrototypeOf(this, EndOfLineError.prototype); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/compiler/types.ts: -------------------------------------------------------------------------------- 1 | export interface Token { 2 | type: 3 | | "leftParen" 4 | | "rightParen" 5 | | "leftSquare" 6 | | "rightSquare" 7 | | "identifier" 8 | | "operator" 9 | | "number" 10 | | "string" 11 | | "boolean" 12 | | "whitespace" 13 | | "comma" 14 | | "unknown"; 15 | value: string; 16 | raw?: string; 17 | start: number; 18 | end: number; 19 | } 20 | 21 | export interface EditorToken extends Token { 22 | variant?: AST["type"]; 23 | hints?: string[]; 24 | valid: boolean; 25 | } 26 | 27 | export type AST = 28 | | Identifier 29 | | Literal 30 | | CallExpression 31 | | BinaryExpression 32 | | ArrayExpression; 33 | 34 | export interface ASTNode { 35 | start: number; 36 | end: number; 37 | } 38 | 39 | export interface BinaryExpression extends ASTNode { 40 | type: "BinaryExpression"; 41 | operator: string; 42 | left: AST; 43 | right: AST; 44 | } 45 | 46 | export interface ArrayExpression extends ASTNode { 47 | type: "ArrayExpression"; 48 | elements: AST[]; 49 | } 50 | 51 | export interface CallExpression extends ASTNode { 52 | type: "CallExpression"; 53 | callee: Identifier; 54 | arguments: AST[]; 55 | } 56 | 57 | export interface Literal extends ASTNode { 58 | type: "Literal"; 59 | value: string | boolean | number; 60 | raw: string; 61 | } 62 | 63 | export interface Identifier extends ASTNode { 64 | type: "Identifier"; 65 | name: string; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/code-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from "react"; 2 | import { CSSProperties } from "react"; 3 | import { Hints } from "./hints"; 4 | import { getTokens, getEditorTokens, buildAST } from "../compiler"; 5 | import { AST, Token, EditorToken } from "../compiler/types"; 6 | import { styles, getComputedStyles, getTokenStyles } from "./styles"; 7 | 8 | export interface CodeInputProps extends React.InputHTMLAttributes<{}> { 9 | symbols?: string[]; 10 | customInputComponent?: React.JSXElementConstructor< 11 | React.InputHTMLAttributes<{}> 12 | >; 13 | style?: CSSProperties; 14 | onChange?: (event: React.SyntheticEvent) => void; 15 | onParse?: (params: { 16 | tokens: Token[]; 17 | ast: AST | null | void; 18 | errors: Error[]; 19 | }) => void; 20 | } 21 | 22 | export function CodeInput(props: CodeInputProps) { 23 | const { 24 | symbols = [], 25 | style = {}, 26 | onChange = () => {}, 27 | onParse, 28 | customInputComponent, 29 | ...inputProps 30 | } = props; 31 | const Input = customInputComponent || "input"; 32 | const inputIsUncontrolled = typeof inputProps.value === "undefined"; 33 | 34 | const [controlledValue, setControlledValue] = useState( 35 | (inputIsUncontrolled && props.defaultValue?.toString()) || "" 36 | ); 37 | 38 | const value = inputIsUncontrolled 39 | ? controlledValue 40 | : inputProps.value?.toString() || ""; 41 | 42 | const sourceTokens = useMemo(() => getTokens(value), [value]); 43 | const tokens = useMemo(() => getEditorTokens(sourceTokens, symbols), [value]); 44 | 45 | const [activeTokenIndex, setActiveTokenIndex] = useState(); 46 | const [hints, setHints] = useState([]); 47 | const [activeHint, setActiveHint] = useState(0); 48 | const [hintOffset, setHintOffset] = useState(0); 49 | const [scrollPosition, setScrollPosition] = useState(0); 50 | const [computedStyles, setComputedStyled] = useState(getComputedStyles(null)); 51 | 52 | const inputRef = React.createRef(); 53 | const tokenRefs = tokens.map(() => React.createRef()); 54 | 55 | useEffect(() => { 56 | let ast; 57 | const errors = tokens 58 | .filter(t => !t.valid) 59 | .map(t => new Error(`Cannot find identifier ${t.value}`)); 60 | 61 | try { 62 | ast = buildAST(sourceTokens); 63 | } catch (err) { 64 | errors.push(err); 65 | } 66 | 67 | inputRef.current?.setCustomValidity(errors.length ? errors[0].message : ""); 68 | 69 | if (typeof onParse === "function") { 70 | onParse({ tokens: sourceTokens, ast, errors }); 71 | } 72 | }, [tokens, sourceTokens]); 73 | 74 | useEffect(() => { 75 | const inputEl = inputRef.current; 76 | const computedStyles = getComputedStyles(inputEl); 77 | setComputedStyled(computedStyles); 78 | }, []); 79 | 80 | const nativeInputSet = (method: string, value: T) => 81 | Object.getOwnPropertyDescriptor( 82 | window.HTMLInputElement.prototype, 83 | method 84 | )?.set?.call(inputRef.current, value); 85 | 86 | const handleChange = (event: React.SyntheticEvent) => { 87 | if (inputIsUncontrolled) { 88 | setControlledValue(event.currentTarget.value); 89 | } 90 | onChange(event); 91 | }; 92 | 93 | const handleKeyDown = (e: React.KeyboardEvent) => { 94 | const ctrl = e.ctrlKey; 95 | const enter = e.key === "Enter"; 96 | const space = e.key === " "; 97 | const esc = e.key === "Escape"; 98 | const up = e.key === "ArrowUp"; 99 | const down = e.key === "ArrowDown"; 100 | const scrollingHints = up || down; 101 | 102 | if (ctrl && space && !hints.length) { 103 | handleSelectToken(e); 104 | setHints(symbols); 105 | } 106 | if (!hints.length) { 107 | return; 108 | } 109 | if (esc) { 110 | setHints([]); 111 | } 112 | if (enter && hints) { 113 | e.preventDefault(); 114 | completeHint(e.currentTarget, activeHint); 115 | } 116 | if (up) { 117 | e.preventDefault(); 118 | const nextIndex = activeHint - 1; 119 | const min = 0; 120 | const max = hints.length - 1; 121 | setActiveHint(nextIndex < min ? max : nextIndex); 122 | } 123 | if (down) { 124 | e.preventDefault(); 125 | const nextIndex = activeHint + 1; 126 | const max = hints.length - 1; 127 | setActiveHint(nextIndex > max ? 0 : nextIndex); 128 | } 129 | if (!scrollingHints) { 130 | handleSelectToken(e); 131 | } 132 | }; 133 | 134 | const handleSelectToken = (e: React.SyntheticEvent) => { 135 | const cursorPosition = e.currentTarget.selectionStart || 0; 136 | 137 | let newActiveToken: EditorToken | null = null; 138 | let len = 0; 139 | for (const token of tokens) { 140 | len += token.value.length; 141 | if (cursorPosition - 1 < len) { 142 | newActiveToken = token; 143 | break; 144 | } 145 | } 146 | if (!newActiveToken) return; 147 | const activeTokenIndex = tokens.indexOf(newActiveToken); 148 | 149 | setActiveTokenIndex(activeTokenIndex); 150 | if (newActiveToken.hints) { 151 | setHints(newActiveToken.hints); 152 | } else { 153 | setHints([]); 154 | } 155 | 156 | const activeToken = tokens[activeTokenIndex]; 157 | const activeTokenRef = tokenRefs[activeTokenIndex]; 158 | const activeTokenEl = activeTokenRef.current; 159 | let offset; 160 | 161 | if (!activeTokenEl) return; 162 | 163 | if (["whitespace", "operator"].includes(activeToken.type)) { 164 | offset = 165 | activeTokenEl.offsetLeft + activeTokenEl.getBoundingClientRect().width; 166 | } else { 167 | offset = activeTokenEl.offsetLeft; 168 | } 169 | 170 | setHintOffset(offset); 171 | }; 172 | 173 | const completeHint = (target: HTMLInputElement, hintIndex: number) => { 174 | const inputIsEmtpy = target.value.length === 0; 175 | 176 | let newCursorPosition = 0; 177 | let completedValue = ""; 178 | 179 | if (inputIsEmtpy) { 180 | completedValue = hints[hintIndex]; 181 | newCursorPosition = completedValue.length; 182 | } else { 183 | tokens.forEach((token, index) => { 184 | if (index === activeTokenIndex) { 185 | if (token.type === "identifier") { 186 | completedValue += hints[hintIndex]; 187 | } else { 188 | completedValue += (token.raw || token.value) + hints[hintIndex]; 189 | } 190 | newCursorPosition = completedValue.length; 191 | } else { 192 | completedValue += token.raw || token.value; 193 | } 194 | }); 195 | } 196 | 197 | setHints([]); 198 | setActiveHint(0); 199 | nativeInputSet("value", completedValue); 200 | nativeInputSet("scrollLeft", target.scrollWidth); 201 | nativeInputSet("selectionStart", newCursorPosition); 202 | nativeInputSet("selectionEnd", newCursorPosition); 203 | inputRef.current?.dispatchEvent(new Event("change", { bubbles: true })); 204 | }; 205 | 206 | return ( 207 | 208 | setScrollPosition(e.currentTarget.scrollLeft)} 216 | onBlur={() => setHints([])} 217 | onFocus={handleSelectToken} 218 | onClick={handleSelectToken} 219 | onSelect={handleSelectToken} 220 | onKeyDown={handleKeyDown} 221 | onChange={handleChange} 222 | /> 223 | 229 | 230 | 237 | {tokens.map((token, i) => ( 238 | 243 | {token.raw || token.value} 244 | 245 | ))} 246 | 247 | 248 | 249 | { 255 | completeHint(inputRef.current as HTMLInputElement, activeHintIndex); 256 | }} 257 | /> 258 | 259 | ); 260 | } 261 | -------------------------------------------------------------------------------- /src/components/hints.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from "react"; 2 | 3 | interface HintsProps { 4 | hints: string[]; 5 | activeIndex: number; 6 | offsetLeft: number; 7 | onSelectHint: (index: number) => any; 8 | inputRef: React.RefObject; 9 | } 10 | 11 | export function Hints({ 12 | hints, 13 | activeIndex, 14 | offsetLeft, 15 | onSelectHint, 16 | inputRef, 17 | }: HintsProps) { 18 | if (!hints.length) return null; 19 | const containerRef = React.createRef(); 20 | const hintRefs = hints.map(() => React.createRef()); 21 | const [styles, setStyles] = React.useState(getComputedStyles(null, 0)); 22 | 23 | React.useLayoutEffect(() => { 24 | const activeHintRef = hintRefs[activeIndex]; 25 | const containerEl = containerRef.current; 26 | const targetEl = activeHintRef.current; 27 | 28 | if (!containerEl || !targetEl) return; 29 | 30 | const targetOffsetTop = targetEl.offsetTop; 31 | const targetOffsetTopFromBottom = 32 | targetEl.offsetTop + targetEl.getBoundingClientRect().height; 33 | const containerHeight = containerEl.getBoundingClientRect().height; 34 | const targetAtBottomOffsetTop = targetOffsetTopFromBottom - containerHeight; 35 | 36 | if (targetAtBottomOffsetTop > containerEl.scrollTop) { 37 | containerEl.scrollTop = targetAtBottomOffsetTop; 38 | } else if (targetOffsetTop < containerEl.scrollTop) { 39 | containerEl.scrollTop = targetOffsetTop; 40 | } 41 | }, [activeIndex]); 42 | 43 | React.useLayoutEffect(() => { 44 | setStyles(getComputedStyles(inputRef.current, offsetLeft)); 45 | }, []); 46 | 47 | return ( 48 | 49 | 50 | 51 | {hints.map((hint, i) => ( 52 | { 60 | e.preventDefault(); 61 | onSelectHint(i); 62 | }} 63 | > 64 | {hint} 65 | 66 | ))} 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | const getComputedStyles = (inputEl: HTMLElement | null, offsetLeft: number) => { 74 | if (!inputEl) return { hints: {}, hint: {}, hintActive: {} }; 75 | const s = getComputedStyle(inputEl); 76 | const inputRect = inputEl.getBoundingClientRect(); 77 | const inputPaddingBottom = Number(s.paddingBottom.replace("px", "")); 78 | const inputFontSize = Number(s.fontSize.replace("px", "")); 79 | const hintPaddingY = 6; 80 | const hintHeight = inputFontSize + hintPaddingY * 2; 81 | const hintTop = 82 | inputRect.height - inputPaddingBottom + inputPaddingBottom / 2.5; 83 | 84 | return { 85 | positioningContainer: { 86 | position: "absolute", 87 | top: hintTop, 88 | } as CSSProperties, 89 | stackingContainer: { 90 | position: "fixed", 91 | zIndex: 999, 92 | } as CSSProperties, 93 | hints: { 94 | display: "inline-block", 95 | position: "absolute", 96 | left: offsetLeft || s.paddingLeft, 97 | minWidth: 300, 98 | maxWidth: 400, 99 | maxHeight: hintHeight * 7, 100 | marginLeft: -1, 101 | overflowX: "hidden", 102 | overflowY: "scroll", 103 | background: "#f9f9f9", 104 | border: "1px solid #dcdcdc", 105 | color: "#111", 106 | } as CSSProperties, 107 | hint: { 108 | boxSizing: "content-box", 109 | height: s.fontSize, 110 | padding: `${hintPaddingY}px 6px`, 111 | fontFamily: s.fontFamily, 112 | fontSize: s.fontSize, 113 | lineHeight: 1, 114 | cursor: "pointer", 115 | } as CSSProperties, 116 | hintActive: { 117 | background: "#4299E1", 118 | color: "white", 119 | } as CSSProperties, 120 | }; 121 | }; 122 | -------------------------------------------------------------------------------- /src/components/styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { EditorToken } from "../compiler"; 3 | 4 | interface ComputedStyles { 5 | container: CSSProperties; 6 | shadowInput: CSSProperties; 7 | shadowInputContainer: CSSProperties; 8 | } 9 | 10 | export const styles: Record = { 11 | container: { 12 | position: "relative", 13 | textAlign: "left", 14 | }, 15 | shadowInputContainer: { 16 | position: "absolute", 17 | left: 0, 18 | right: 0, 19 | top: 0, 20 | pointerEvents: "none", 21 | }, 22 | input: { 23 | color: "transparent", 24 | caretColor: "black", 25 | maxWidth: "100%", 26 | }, 27 | shadowInput: { 28 | borderColor: "transparent", 29 | }, 30 | }; 31 | 32 | export const getComputedStyles = ( 33 | inputEl: HTMLElement | null 34 | ): ComputedStyles => { 35 | if (!inputEl) { 36 | return { 37 | container: {}, 38 | shadowInput: {}, 39 | shadowInputContainer: {}, 40 | }; 41 | } 42 | const s = getComputedStyle(inputEl); 43 | return { 44 | container: { 45 | display: s.display, 46 | }, 47 | shadowInput: { 48 | width: s.width, 49 | fontFamily: s.fontFamily, 50 | fontWeight: s.fontWeight, 51 | fontSize: s.fontSize, 52 | lineHeight: s.lineHeight, 53 | borderTopStyle: s.borderTopStyle, 54 | borderBottomStyle: s.borderBottomStyle, 55 | borderLeftStyle: s.borderLeftStyle, 56 | borderRightStyle: s.borderRightStyle, 57 | borderTopWidth: s.borderTopWidth, 58 | borderBottomWidth: s.borderBottomWidth, 59 | borderLeftWidth: s.borderLeftWidth, 60 | borderRightWidth: s.borderRightWidth, 61 | }, 62 | shadowInputContainer: { 63 | paddingLeft: s.paddingLeft, 64 | paddingRight: s.paddingRight, 65 | paddingTop: s.paddingTop, 66 | paddingBottom: s.paddingBottom, 67 | }, 68 | } as ComputedStyles; 69 | }; 70 | 71 | export const getTokenStyles = ({ type, variant, valid }: EditorToken) => { 72 | const style: CSSProperties = { 73 | position: "relative", 74 | display: "inline", 75 | borderBottom: valid ? undefined : "2px dotted red", 76 | }; 77 | if (type === "identifier" && variant === "CallExpression") { 78 | style.color = "rgb(0, 112, 230)"; 79 | } else if (type === "identifier" && valid) { 80 | style.color = "rgb(189, 131, 16)"; 81 | } else if (type === "number") { 82 | style.color = "rgb(0, 170, 123)"; 83 | } else if (type === "string") { 84 | style.color = "rgb(171, 71, 41)"; 85 | } else { 86 | style.color = "rgb(20, 23, 24)"; 87 | } 88 | return style; 89 | }; 90 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components/code-input"; 2 | export * from "./compiler"; 3 | -------------------------------------------------------------------------------- /stories/code-input.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, Story } from "@storybook/react"; 3 | import { CodeInput, CodeInputProps } from "../src"; 4 | 5 | const meta: Meta = { 6 | title: "CodeInput", 7 | component: CodeInput, 8 | argTypes: { 9 | symbols: { control: { type: "array" } }, 10 | }, 11 | args: { 12 | symbols: [ 13 | "accruedInterest", 14 | "adjustedDiscountPrice", 15 | "all", 16 | "any", 17 | "commission", 18 | "costs", 19 | "cpa", 20 | "cvr", 21 | "price", 22 | "profit", 23 | "salePrice", 24 | "vat", 25 | ], 26 | placeholder: "any(salePrice - vat, cpa)", 27 | }, 28 | parameters: { 29 | controls: { expanded: true }, 30 | }, 31 | }; 32 | 33 | export default meta; 34 | 35 | const Template: Story = args => ; 36 | 37 | export const Default = Template.bind({}); 38 | Default.args = {}; 39 | 40 | export const CustomStyles = Template.bind({}); 41 | CustomStyles.args = { 42 | style: { 43 | width: "350px", 44 | padding: "10px", 45 | fontSize: "14px", 46 | fontFamily: "monospace", 47 | }, 48 | }; 49 | 50 | export const ControlledInput = () => { 51 | const [state, setState] = React.useState("123"); 52 | return ( 53 | { 57 | setState(e.currentTarget.value.toUpperCase()); 58 | }} 59 | /> 60 | ); 61 | }; 62 | 63 | const ExampleCustomInput = React.forwardRef((props, ref) => ( 64 | 65 | )); 66 | 67 | export const CustomComponent = Template.bind({}); 68 | CustomComponent.args = { 69 | customInputComponent: ExampleCustomInput, 70 | }; 71 | -------------------------------------------------------------------------------- /test/lexer.test.ts: -------------------------------------------------------------------------------- 1 | import { getTokens } from "../src/compiler/lexer"; 2 | 3 | describe("lexer", () => { 4 | test("1", () => { 5 | const tokens = getTokens("1"); 6 | expect(tokens).toEqual([{ start: 0, end: 1, type: "number", value: "1" }]); 7 | }); 8 | 9 | test(`"1"`, () => { 10 | const tokens = getTokens(`"1"`); 11 | expect(tokens).toEqual([ 12 | { start: 0, end: 3, type: "string", value: "1", raw: `"1"` }, 13 | ]); 14 | }); 15 | 16 | test("1 + 1", () => { 17 | const tokens = getTokens("1 + 1"); 18 | expect(tokens).toEqual([ 19 | { start: 0, end: 1, type: "number", value: "1" }, 20 | { start: 1, end: 2, type: "whitespace", value: " ", raw: "\u00A0" }, 21 | { start: 2, end: 3, type: "operator", value: "+" }, 22 | { start: 3, end: 4, type: "whitespace", value: " ", raw: "\u00A0" }, 23 | { start: 4, end: 5, type: "number", value: "1" }, 24 | ]); 25 | }); 26 | 27 | test(`123 + "123"`, () => { 28 | const tokens = getTokens(`123 + "123"`); 29 | expect(tokens).toEqual([ 30 | { start: 0, end: 3, type: "number", value: "123" }, 31 | { start: 3, end: 4, type: "whitespace", value: " ", raw: "\u00A0" }, 32 | { start: 4, end: 5, type: "operator", value: "+" }, 33 | { start: 5, end: 6, type: "whitespace", value: " ", raw: "\u00A0" }, 34 | { start: 6, end: 11, type: "string", value: "123", raw: `"123"` }, 35 | ]); 36 | }); 37 | 38 | test(`123 + num`, () => { 39 | const tokens = getTokens(`123 + num`); 40 | expect(tokens).toEqual([ 41 | { start: 0, end: 3, type: "number", value: "123" }, 42 | { start: 3, end: 4, type: "whitespace", value: " ", raw: "\u00A0" }, 43 | { start: 4, end: 5, type: "operator", value: "+" }, 44 | { start: 5, end: 6, type: "whitespace", value: " ", raw: "\u00A0" }, 45 | { start: 6, end: 9, type: "identifier", value: "num" }, 46 | ]); 47 | }); 48 | 49 | test(`sum(123, 345)`, () => { 50 | const tokens = getTokens(`sum(123, 345)`); 51 | expect(tokens).toEqual([ 52 | { start: 0, end: 3, type: "identifier", value: "sum" }, 53 | { start: 3, end: 4, type: "leftParen", value: "(" }, 54 | { start: 4, end: 7, type: "number", value: "123" }, 55 | { start: 7, end: 8, type: "comma", value: "," }, 56 | { start: 8, end: 9, type: "whitespace", value: " ", raw: "\u00A0" }, 57 | { start: 9, end: 12, type: "number", value: "345" }, 58 | { start: 12, end: 13, type: "rightParen", value: ")" }, 59 | ]); 60 | }); 61 | 62 | test(`sum(true, false)`, () => { 63 | const tokens = getTokens(`sum(true, false)`); 64 | expect(tokens).toEqual([ 65 | { start: 0, end: 3, type: "identifier", value: "sum" }, 66 | { start: 3, end: 4, type: "leftParen", value: "(" }, 67 | { start: 4, end: 8, type: "boolean", value: "true" }, 68 | { start: 8, end: 9, type: "comma", value: "," }, 69 | { start: 9, end: 10, type: "whitespace", value: " ", raw: "\u00A0" }, 70 | { start: 10, end: 15, type: "boolean", value: "false" }, 71 | { start: 15, end: 16, type: "rightParen", value: ")" }, 72 | ]); 73 | }); 74 | 75 | test(`sum([123, 345])`, () => { 76 | const tokens = getTokens(`sum([123, 345])`); 77 | expect(tokens).toEqual([ 78 | { start: 0, end: 3, type: "identifier", value: "sum" }, 79 | { start: 3, end: 4, type: "leftParen", value: "(" }, 80 | { start: 4, end: 5, type: "leftSquare", value: "[" }, 81 | { start: 5, end: 8, type: "number", value: "123" }, 82 | { start: 8, end: 9, type: "comma", value: "," }, 83 | { start: 9, end: 10, type: "whitespace", value: " ", raw: "\u00A0" }, 84 | { start: 10, end: 13, type: "number", value: "345" }, 85 | { start: 13, end: 14, type: "rightSquare", value: "]" }, 86 | { start: 14, end: 15, type: "rightParen", value: ")" }, 87 | ]); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { getTokens } from "../src/compiler/lexer"; 2 | import { 3 | buildAST, 4 | EndOfLineError, 5 | UnexpectedTokenError, 6 | UnknownTokenError, 7 | } from "../src/compiler/parser"; 8 | 9 | describe("parser", () => { 10 | test("empty string", () => { 11 | const tokens = getTokens(""); 12 | const ast = buildAST(tokens); 13 | expect(ast).toEqual(null); 14 | }); 15 | 16 | test("1 +", () => { 17 | const tokens = getTokens("1 +"); 18 | expect(() => buildAST(tokens)).toThrow(EndOfLineError); 19 | }); 20 | 21 | test(`any(1, 2`, () => { 22 | const tokens = getTokens(`any(1, 2`); 23 | expect(() => buildAST(tokens)).toThrow(EndOfLineError); 24 | }); 25 | 26 | test(`[1, 2,`, () => { 27 | const tokens = getTokens(`[1, 2,`); 28 | expect(() => buildAST(tokens)).toThrow(EndOfLineError); 29 | }); 30 | 31 | test(`1 fn(1)`, () => { 32 | const tokens = getTokens(`1 fn(1)`); 33 | expect(() => buildAST(tokens)).toThrow(UnexpectedTokenError); 34 | }); 35 | 36 | test(`1 1`, () => { 37 | const tokens = getTokens(`1 1`); 38 | expect(() => buildAST(tokens)).toThrow(UnexpectedTokenError); 39 | }); 40 | 41 | test(`1 + $1`, () => { 42 | const tokens = getTokens(`1 + $1`); 43 | expect(() => buildAST(tokens)).toThrow(UnknownTokenError); 44 | }); 45 | 46 | test(`1 bar`, () => { 47 | const tokens = getTokens(`1 bar`); 48 | expect(() => buildAST(tokens)).toThrow(UnexpectedTokenError); 49 | }); 50 | 51 | test(`foo(1 bar)`, () => { 52 | const tokens = getTokens(`foo(1 bar)`); 53 | expect(() => buildAST(tokens)).toThrow(UnexpectedTokenError); 54 | }); 55 | 56 | test(`foo(1bar)`, () => { 57 | const tokens = getTokens(`foo(1bar)`); 58 | expect(() => buildAST(tokens)).toThrow(UnexpectedTokenError); 59 | }); 60 | 61 | test("12", () => { 62 | const tokens = getTokens("12"); 63 | const ast = buildAST(tokens); 64 | expect(ast).toEqual({ 65 | type: "Literal", 66 | value: 12, 67 | start: 0, 68 | end: 2, 69 | }); 70 | }); 71 | 72 | test(`"123hello"`, () => { 73 | const tokens = getTokens(`"123hello"`); 74 | const ast = buildAST(tokens); 75 | expect(ast).toEqual({ 76 | type: "Literal", 77 | value: "123hello", 78 | start: 0, 79 | end: 10, 80 | }); 81 | }); 82 | 83 | test(`[1,2]`, () => { 84 | const tokens = getTokens(`[1,2]`); 85 | const ast = buildAST(tokens); 86 | expect(ast).toEqual({ 87 | type: "ArrayExpression", 88 | elements: [ 89 | { type: "Literal", value: 1, start: 1, end: 2 }, 90 | { type: "Literal", value: 2, start: 3, end: 4 }, 91 | ], 92 | start: 0, 93 | end: 5, 94 | }); 95 | }); 96 | 97 | test("1 + 23", () => { 98 | const tokens = getTokens("1 + 23"); 99 | const ast = buildAST(tokens); 100 | 101 | expect(ast).toEqual({ 102 | type: "BinaryExpression", 103 | operator: "+", 104 | left: { type: "Literal", value: 1, start: 0, end: 1 }, 105 | right: { type: "Literal", value: 23, start: 4, end: 6 }, 106 | start: 0, 107 | end: 6, 108 | }); 109 | }); 110 | 111 | test("1 - 23 + 3", () => { 112 | const tokens = getTokens("1 - 23 + 3"); 113 | const ast = buildAST(tokens); 114 | 115 | expect(ast).toEqual({ 116 | type: "BinaryExpression", 117 | operator: "+", 118 | left: { 119 | type: "BinaryExpression", 120 | operator: "-", 121 | left: { type: "Literal", value: 1, start: 0, end: 1 }, 122 | right: { type: "Literal", value: 23, start: 4, end: 6 }, 123 | start: 0, 124 | end: 6, 125 | }, 126 | right: { type: "Literal", value: 3, start: 9, end: 10 }, 127 | start: 0, 128 | end: 10, 129 | }); 130 | }); 131 | 132 | test("1 / 23 - 3", () => { 133 | const tokens = getTokens("1 / 23 - 3"); 134 | const ast = buildAST(tokens); 135 | 136 | expect(ast).toEqual({ 137 | type: "BinaryExpression", 138 | operator: "-", 139 | left: { 140 | type: "BinaryExpression", 141 | operator: "/", 142 | left: { type: "Literal", value: 1, start: 0, end: 1 }, 143 | right: { type: "Literal", value: 23, start: 4, end: 6 }, 144 | start: 0, 145 | end: 6, 146 | }, 147 | right: { type: "Literal", value: 3, start: 9, end: 10 }, 148 | start: 0, 149 | end: 10, 150 | }); 151 | }); 152 | 153 | test("1 - 23 * 3", () => { 154 | const tokens = getTokens("1 - 23 * 3"); 155 | const ast = buildAST(tokens); 156 | 157 | expect(ast).toEqual({ 158 | type: "BinaryExpression", 159 | operator: "-", 160 | left: { type: "Literal", value: 1, start: 0, end: 1 }, 161 | right: { 162 | type: "BinaryExpression", 163 | operator: "*", 164 | left: { type: "Literal", value: 23, start: 4, end: 6 }, 165 | right: { type: "Literal", value: 3, start: 9, end: 10 }, 166 | start: 4, 167 | end: 10, 168 | }, 169 | start: 0, 170 | end: 10, 171 | }); 172 | }); 173 | 174 | test("3/4*5", () => { 175 | const tokens = getTokens("3/4*5"); 176 | const ast = buildAST(tokens); 177 | 178 | expect(ast).toMatchObject({ 179 | type: "BinaryExpression", 180 | operator: "*", 181 | left: { 182 | type: "BinaryExpression", 183 | operator: "/", 184 | left: { type: "Literal", value: 3 }, 185 | right: { type: "Literal", value: 4 }, 186 | }, 187 | right: { type: "Literal", value: 5 }, 188 | }); 189 | }); 190 | 191 | test("1*2-3/4*5", () => { 192 | const tokens = getTokens("1*2-3/4*5"); 193 | const ast = buildAST(tokens); 194 | 195 | expect(ast).toMatchObject({ 196 | type: "BinaryExpression", 197 | operator: "-", 198 | left: { 199 | type: "BinaryExpression", 200 | operator: "*", 201 | left: { type: "Literal", value: 1 }, 202 | right: { type: "Literal", value: 2 }, 203 | }, 204 | right: { 205 | type: "BinaryExpression", 206 | operator: "*", 207 | left: { 208 | type: "BinaryExpression", 209 | operator: "/", 210 | left: { type: "Literal", value: 3 }, 211 | right: { type: "Literal", value: 4 }, 212 | }, 213 | right: { type: "Literal", value: 5 }, 214 | }, 215 | }); 216 | }); 217 | 218 | test("1 * (2 + (3 / 4 - 5))", () => { 219 | const tokens = getTokens("1 * (2 + (3 / 4 - 5))"); 220 | const ast = buildAST(tokens); 221 | 222 | expect(ast).toMatchObject({ 223 | type: "BinaryExpression", 224 | operator: "*", 225 | left: { type: "Literal", value: 1 }, 226 | right: { 227 | type: "BinaryExpression", 228 | operator: "+", 229 | left: { type: "Literal", value: 2 }, 230 | right: { 231 | type: "BinaryExpression", 232 | operator: "-", 233 | left: { 234 | type: "BinaryExpression", 235 | operator: "/", 236 | left: { type: "Literal", value: 3 }, 237 | right: { type: "Literal", value: 4 }, 238 | }, 239 | right: { type: "Literal", value: 5 }, 240 | }, 241 | }, 242 | }); 243 | }); 244 | 245 | test(`123 + num`, () => { 246 | const tokens = getTokens(`123 + num`); 247 | const ast = buildAST(tokens); 248 | 249 | expect(ast).toMatchObject({ 250 | type: "BinaryExpression", 251 | operator: "+", 252 | left: { type: "Literal", value: 123 }, 253 | right: { type: "Identifier", name: "num" }, 254 | }); 255 | }); 256 | 257 | test(`((123 + num))`, () => { 258 | const tokens = getTokens(`((123 + num))`); 259 | const ast = buildAST(tokens); 260 | 261 | expect(ast).toMatchObject({ 262 | type: "BinaryExpression", 263 | operator: "+", 264 | left: { type: "Literal", value: 123 }, 265 | right: { type: "Identifier", name: "num" }, 266 | }); 267 | }); 268 | 269 | test(`sum(1,2,3,)`, () => { 270 | const tokens = getTokens(`sum(1,2,3,)`); 271 | const ast = buildAST(tokens); 272 | 273 | expect(ast).toMatchObject({ 274 | type: "CallExpression", 275 | callee: { type: "Identifier", name: "sum" }, 276 | arguments: [ 277 | { type: "Literal", value: 1 }, 278 | { type: "Literal", value: 2 }, 279 | { type: "Literal", value: 3 }, 280 | ], 281 | }); 282 | }); 283 | 284 | test(`sum(123, (1 + 2), true)`, () => { 285 | const tokens = getTokens(`sum(123, (1 + 2), true)`); 286 | const ast = buildAST(tokens); 287 | 288 | expect(ast).toMatchObject({ 289 | type: "CallExpression", 290 | callee: { type: "Identifier", name: "sum" }, 291 | arguments: [ 292 | { type: "Literal", value: 123 }, 293 | { 294 | type: "BinaryExpression", 295 | operator: "+", 296 | left: { type: "Literal", value: 1 }, 297 | right: { type: "Literal", value: 2 }, 298 | start: 10, 299 | end: 15, 300 | }, 301 | { type: "Literal", value: true, start: 18, end: 22 }, 302 | ], 303 | start: 0, 304 | end: 23, 305 | }); 306 | }); 307 | 308 | test(`sum([123, 345], false)`, () => { 309 | const tokens = getTokens(`sum([123, 345], false)`); 310 | const ast = buildAST(tokens); 311 | 312 | expect(ast).toMatchObject({ 313 | type: "CallExpression", 314 | callee: { type: "Identifier", name: "sum" }, 315 | arguments: [ 316 | { 317 | type: "ArrayExpression", 318 | elements: [ 319 | { type: "Literal", value: 123 }, 320 | { type: "Literal", value: 345 }, 321 | ], 322 | }, 323 | { type: "Literal", value: false }, 324 | ], 325 | }); 326 | }); 327 | 328 | test(`1 + sum(1,2,3)`, () => { 329 | const tokens = getTokens(`1 + sum(1,2,3)`); 330 | const ast = buildAST(tokens); 331 | 332 | expect(ast).toMatchObject({ 333 | type: "BinaryExpression", 334 | left: { type: "Literal", value: 1 }, 335 | operator: "+", 336 | start: 0, 337 | end: 14, 338 | right: { 339 | type: "CallExpression", 340 | callee: { type: "Identifier", name: "sum" }, 341 | arguments: [ 342 | { type: "Literal", value: 1, start: 8, end: 9 }, 343 | { type: "Literal", value: 2, start: 10, end: 11 }, 344 | { type: "Literal", value: 3, start: 12, end: 13 }, 345 | ], 346 | start: 4, 347 | end: 14, 348 | }, 349 | }); 350 | }); 351 | 352 | test(`1 + [1,2,3]`, () => { 353 | const tokens = getTokens(`1 + [1,2,3]`); 354 | const ast = buildAST(tokens); 355 | expect(ast).toMatchObject({ 356 | type: "BinaryExpression", 357 | left: { type: "Literal", value: 1 }, 358 | operator: "+", 359 | right: { 360 | type: "ArrayExpression", 361 | elements: [ 362 | { type: "Literal", value: 1 }, 363 | { type: "Literal", value: 2 }, 364 | { type: "Literal", value: 3 }, 365 | ], 366 | }, 367 | }); 368 | }); 369 | 370 | test("any(a + b) - c", () => { 371 | const tokens = getTokens("any(a + b) - c"); 372 | expect(() => buildAST(tokens)).not.toThrow(); 373 | }); 374 | 375 | test("any(a, all(b, c))", () => { 376 | const tokens = getTokens("any(a, all(b, c))"); 377 | const ast = buildAST(tokens); 378 | expect(ast).toMatchObject({ 379 | type: "CallExpression", 380 | callee: { 381 | type: "Identifier", 382 | name: "any", 383 | start: 0, 384 | end: 3, 385 | }, 386 | arguments: [ 387 | { 388 | type: "Identifier", 389 | name: "a", 390 | start: 4, 391 | end: 5, 392 | }, 393 | { 394 | type: "CallExpression", 395 | callee: { 396 | type: "Identifier", 397 | name: "all", 398 | start: 7, 399 | end: 10, 400 | }, 401 | arguments: [ 402 | { 403 | type: "Identifier", 404 | name: "b", 405 | start: 11, 406 | end: 12, 407 | }, 408 | { 409 | type: "Identifier", 410 | name: "c", 411 | start: 14, 412 | end: 15, 413 | }, 414 | ], 415 | start: 7, 416 | end: 16, 417 | }, 418 | ], 419 | start: 0, 420 | end: 17, 421 | }); 422 | }); 423 | 424 | test("any(a, all(b, c)) + d", () => { 425 | const tokens = getTokens("any(a, all(b, c)) + d"); 426 | expect(() => buildAST(tokens)).not.toThrow(); 427 | }); 428 | 429 | test(`(fn(1) + 1)`, () => { 430 | const tokens = getTokens(`(fn(1) + 1)`); 431 | expect(() => buildAST(tokens)).not.toThrow(); 432 | }); 433 | 434 | test(`[1,2] + 1`, () => { 435 | const tokens = getTokens(`[1,2] + 1`); 436 | expect(() => buildAST(tokens)).not.toThrow(); 437 | }); 438 | }); 439 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /website/.env.local: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-code-input-demo", 3 | "version": "1.0.0", 4 | "main": "src/index.tsx", 5 | "dependencies": { 6 | "@djgrant/react-code-input": "0.8.2", 7 | "react": "^17.0.0", 8 | "react-dom": "^17.0.0", 9 | "react-scripts": "^4.0.0" 10 | }, 11 | "devDependencies": { 12 | "@types/react": "^17.0.0", 13 | "@types/react-dom": "^17.0.0", 14 | "typescript": "^4.1.3" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /website/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 25 | React Code Input | @djgrant 26 | 27 | 28 | 29 | 30 | You need to enable JavaScript to run this app. 31 | 32 | 33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /website/src/Demo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CodeInput, AST } from "@djgrant/react-code-input"; 3 | 4 | export const Demo = () => { 5 | const initialValue = "sum(accruedInterest, costs) / (commission * 10.5)"; 6 | const [value, setValue] = React.useState(initialValue); 7 | const [ast, setAST] = React.useState(); 8 | const [errors, setErrors] = React.useState([]); 9 | return ( 10 | 11 | { 14 | setAST(ast); 15 | setErrors(errors); 16 | }} 17 | onChange={e => { 18 | setValue(e.currentTarget.value); 19 | }} 20 | className="input" 21 | symbols={[ 22 | "accruedInterest", 23 | "adjustedDiscountPrice", 24 | "all", 25 | "any", 26 | "commission", 27 | "costs", 28 | "cpa", 29 | "cvr", 30 | "price", 31 | "profit", 32 | "salePrice", 33 | "sum", 34 | "vat", 35 | ]} 36 | style={{ 37 | display: "block", 38 | width: "100%", 39 | marginBottom: "10px", 40 | }} 41 | /> 42 | 43 | Tip: press ctrl + space to trigger autocomplete 44 | 45 | 46 | <> 47 | {errors.length > 0 && 48 | errors.map(e => ( 49 | {e.message} 50 | ))} 51 | {ast && JSON.stringify(ast, null, 4)} 52 | > 53 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /website/src/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const Header = () => ( 4 | 5 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | React Code Input 23 | 24 | 25 | 26 | Turn <input /> into a mini code editor 27 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /website/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render } from "react-dom"; 3 | import { Header } from "./Header"; 4 | import { Demo } from "./Demo"; 5 | 6 | import "./styles.css"; 7 | 8 | const rootElement = document.getElementById("root"); 9 | 10 | render( 11 | <> 12 | 13 | 14 | >, 15 | rootElement 16 | ); 17 | -------------------------------------------------------------------------------- /website/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /website/src/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | font-family: sans-serif; 7 | font-size: 16px; 8 | } 9 | 10 | h1 { 11 | font-size: 40px; 12 | letter-spacing: -1px; 13 | } 14 | 15 | a { 16 | text-decoration: none; 17 | color: rgb(0, 112, 230); 18 | } 19 | 20 | a:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | small { 25 | font-size: 11px; 26 | color: #999; 27 | } 28 | 29 | .container { 30 | max-width: 750px; 31 | margin: 0 auto; 32 | } 33 | 34 | input::placeholder { 35 | color: #999; 36 | } 37 | 38 | .input { 39 | display: block; 40 | width: 100%; 41 | padding: 12px; 42 | font-size: 15px; 43 | font-family: monospace; 44 | line-height: 19px; 45 | letter-spacing: normal; 46 | border: 1px solid #ddd; 47 | border-radius: 5px; 48 | background: #eee; 49 | transition: 0.2s box-shadow ease; 50 | } 51 | 52 | .input:focus { 53 | outline: 2px solid transparent; 54 | border: 1px solid rgba(66, 153, 225, 0.45); 55 | box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1); 56 | } 57 | 58 | .input:invalid { 59 | border: 1px solid rgba(255, 20, 20, 0.36); 60 | } 61 | 62 | .input:invalid:focus { 63 | box-shadow: 0 0 0 3px rgba(255, 0, 0, 0.08); 64 | } 65 | 66 | .parser-results { 67 | margin-top: 30px; 68 | padding: 24px; 69 | border: 1px solid #eee; 70 | border-radius: 5px; 71 | background: #eee; 72 | font-size: 14px; 73 | } 74 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "lib": [ 9 | "dom", 10 | "es2015" 11 | ], 12 | "jsx": "react-jsx", 13 | "target": "es5", 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "allowSyntheticDefaultImports": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "noFallthroughCasesInSwitch": true 24 | } 25 | } 26 | --------------------------------------------------------------------------------
46 | <> 47 | {errors.length > 0 && 48 | errors.map(e => ( 49 | {e.message} 50 | ))} 51 | {ast && JSON.stringify(ast, null, 4)} 52 | > 53 |
26 | Turn <input /> into a mini code editor 27 |
<input />