├── .babelrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .umirc.ts ├── LICENSE ├── README.md ├── demo.gif ├── package.json ├── src ├── index.ts └── json5-editor │ ├── Editor.js │ ├── constant │ └── index.ts │ ├── hooks │ ├── useUpdateEffect.ts │ └── useWidth.ts │ ├── index.md │ ├── index.tsx │ ├── style.less │ └── utils │ ├── autoComplete.ts │ ├── format.ts │ ├── lineNumber.tsx │ ├── match.ts │ └── prism.ts ├── tsconfig.esm.json ├── tsconfig.json ├── typings.d.ts └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/preset-react", 5 | ["@babel/preset-typescript", { "allExtensions": true, "isTSX": true }] 6 | ], 7 | "plugins": ["@babel/plugin-proposal-class-properties"] 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | /docs-dist 13 | 14 | # misc 15 | .DS_Store 16 | 17 | # umi 18 | .umi 19 | .umi-production 20 | .umi-test 21 | .env.local 22 | 23 | # ide 24 | /.vscode 25 | /.idea 26 | .editorconfig -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.svg 2 | **/*.ejs 3 | **/*.html 4 | package.json 5 | .umi 6 | .umi-production 7 | .umi-test 8 | dist 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 200, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | export default defineConfig({ 4 | title: 'json5-editor', 5 | favicon: 'https://avatars.githubusercontent.com/u/19804057?s=60&v=4', 6 | logo: 'https://avatars.githubusercontent.com/u/19804057?s=60&v=4', 7 | outputPath: 'docs-dist', 8 | base: '/json5-editor', 9 | publicPath: '/json5-editor/', 10 | exportStatic: {}, 11 | hash: true, 12 | }); 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Troy Li 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 | # json5-editor 2 | 3 | A lite JSON5 editor with smart autoComplete and zero configuration 4 | 5 | [NPM](https://www.npmjs.com/package/json5-editor) | [Github](https://github.com/ttys026/json5-editor) | [Playground](https://ttys026.github.io/json5-editor/json5-editor) 6 | 7 | ![demo](https://github.com/ttys026/json5-editor/blob/master/demo.gif?raw=true) 8 | 9 | ### usage 10 | 11 | ``` 12 | import { Editor } from 'json5-editor' 13 | 14 | export default () => { 15 | return ( 16 | 17 | ) 18 | } 19 | ``` 20 | 21 | ### feature 22 | 23 | - Syntax highlight 24 | - Auto formatting & error correcting 25 | - Duplicate property name checking 26 | - Brace matching 27 | - code ruler 28 | 29 | **Not optimized for big JSON, use at your own risk** 30 | 31 | > more about how it works and limitations, please refer to https://github.com/satya164/react-simple-code-editor#how-it-works 32 | 33 | ### API 34 | 35 | | prop | description | type | default | 36 | | ------------ | ----------------------------------------------------------- | ------------------- | ------- | 37 | | initialValue | default value of textarea | string | '' | 38 | | value | value in the textarea, required in controlled mode | string | - | 39 | | onChange | textarea value change callback, required in controlled mode | (v: string) => void | - | 40 | | placeholder | placeholder of textarea | string | '' | 41 | | style | className of textarea and pre tag | React.CSSProperties | - | 42 | | className | className of outer container | string | - | 43 | | disabled | whether the editor is disbled | boolean | false | 44 | | readOnly | whether the editor is readonly | boolean | false | 45 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttys026/json5-editor/3fe205c841d7b3c9cae511a8b3bc7565afcd1e97/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json5-editor", 3 | "version": "1.3.9", 4 | "scripts": { 5 | "start": "dumi dev", 6 | "docs:build": "dumi build", 7 | "docs:deploy": "gh-pages -d docs-dist", 8 | "build": "tsc && webpack", 9 | "deploy": "npm run docs:build && npm run docs:deploy", 10 | "release": "npm run build && npm publish", 11 | "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", 12 | "test": "umi-test", 13 | "test:coverage": "umi-test --coverage" 14 | }, 15 | "main": "dist/index.js", 16 | "typings": "dist/index.d.ts", 17 | "gitHooks": { 18 | "pre-commit": "lint-staged" 19 | }, 20 | "lint-staged": { 21 | "*.{js,jsx,less,md,json}": [ 22 | "prettier --write" 23 | ], 24 | "*.ts?(x)": [ 25 | "prettier --parser=typescript --write" 26 | ] 27 | }, 28 | "license": "MIT", 29 | "dependencies": { 30 | "classnames": "^2.3.0", 31 | "prismjs": "^1.23.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/preset-env": "^7.13.10", 35 | "@babel/preset-react": "^7.12.13", 36 | "@babel/preset-typescript": "^7.13.0", 37 | "@types/prismjs": "^1.16.3", 38 | "@umijs/test": "^3.0.5", 39 | "antd": "^4.14.1", 40 | "babel": "^6.23.0", 41 | "babel-loader": "^8.2.2", 42 | "babel-plugin-transform-class-properties": "^6.24.1", 43 | "css-loader": "^5.1.3", 44 | "dumi": "^1.0.13", 45 | "gh-pages": "^3.0.0", 46 | "less": "^4.1.1", 47 | "less-loader": "^5.0.0", 48 | "lint-staged": "^10.0.7", 49 | "prettier": "^1.19.1", 50 | "react": "^16.12.0", 51 | "style-loader": "^2.0.0", 52 | "typescript": "^4.2.3", 53 | "webpack": "^4.40.2", 54 | "webpack-cli": "^3.3.9", 55 | "yorkie": "^2.0.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Editor, formatJSON5 } from './json5-editor'; 2 | -------------------------------------------------------------------------------- /src/json5-editor/Editor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, '__esModule', { 4 | value: true, 5 | }); 6 | 7 | var _extends = 8 | Object.assign || 9 | function (target) { 10 | for (var i = 1; i < arguments.length; i++) { 11 | var source = arguments[i]; 12 | for (var key in source) { 13 | if (Object.prototype.hasOwnProperty.call(source, key)) { 14 | target[key] = source[key]; 15 | } 16 | } 17 | } 18 | return target; 19 | }; 20 | 21 | var _createClass = (function () { 22 | function defineProperties(target, props) { 23 | for (var i = 0; i < props.length; i++) { 24 | var descriptor = props[i]; 25 | descriptor.enumerable = descriptor.enumerable || false; 26 | descriptor.configurable = true; 27 | if ('value' in descriptor) descriptor.writable = true; 28 | Object.defineProperty(target, descriptor.key, descriptor); 29 | } 30 | } 31 | return function (Constructor, protoProps, staticProps) { 32 | if (protoProps) defineProperties(Constructor.prototype, protoProps); 33 | if (staticProps) defineProperties(Constructor, staticProps); 34 | return Constructor; 35 | }; 36 | })(); 37 | 38 | var _react = require('react'); 39 | 40 | var React = _interopRequireWildcard(_react); 41 | 42 | function _interopRequireWildcard(obj) { 43 | if (obj && obj.__esModule) { 44 | return obj; 45 | } else { 46 | var newObj = {}; 47 | if (obj != null) { 48 | for (var key in obj) { 49 | if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = obj[key]; 50 | } 51 | } 52 | newObj.default = obj; 53 | return newObj; 54 | } 55 | } 56 | 57 | function _objectWithoutProperties(obj, keys) { 58 | var target = {}; 59 | for (var i in obj) { 60 | if (keys.indexOf(i) >= 0) continue; 61 | if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; 62 | target[i] = obj[i]; 63 | } 64 | return target; 65 | } 66 | 67 | function _classCallCheck(instance, Constructor) { 68 | if (!(instance instanceof Constructor)) { 69 | throw new TypeError('Cannot call a class as a function'); 70 | } 71 | } 72 | 73 | function _possibleConstructorReturn(self, call) { 74 | if (!self) { 75 | throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); 76 | } 77 | return call && (typeof call === 'object' || typeof call === 'function') ? call : self; 78 | } 79 | 80 | function _inherits(subClass, superClass) { 81 | if (typeof superClass !== 'function' && superClass !== null) { 82 | throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); 83 | } 84 | subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); 85 | if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : (subClass.__proto__ = superClass); 86 | } 87 | /* global global */ 88 | 89 | var KEYCODE_ENTER = 13; 90 | var KEYCODE_TAB = 9; 91 | var KEYCODE_BACKSPACE = 8; 92 | var KEYCODE_Y = 89; 93 | var KEYCODE_Z = 90; 94 | var KEYCODE_M = 77; 95 | var KEYCODE_PARENS = 57; 96 | var KEYCODE_BRACKETS = 219; 97 | var KEYCODE_QUOTE = 222; 98 | var KEYCODE_BACK_QUOTE = 192; 99 | var KEYCODE_ESCAPE = 27; 100 | 101 | var HISTORY_LIMIT = 100; 102 | var HISTORY_TIME_GAP = 3000; 103 | 104 | var isWindows = 'navigator' in global && /Win/i.test(navigator.platform); 105 | var isMacLike = 'navigator' in global && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); 106 | 107 | var className = 'npm__react-simple-code-editor__textarea'; 108 | 109 | var cssText = 110 | /* CSS */ '\n/**\n * Reset the text fill color so that placeholder is visible\n */\n.' + 111 | className + 112 | ":empty {\n -webkit-text-fill-color: inherit !important;\n}\n\n/**\n * Hack to apply on some CSS on IE10 and IE11\n */\n@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {\n /**\n * IE doesn't support '-webkit-text-fill-color'\n * So we use 'color: transparent' to make the text transparent on IE\n * Unlike other browsers, it doesn't affect caret color in IE\n */\n ." + 113 | className + 114 | ' {\n color: transparent !important;\n }\n\n .' + 115 | className + 116 | '::selection {\n background-color: #accef7 !important;\n color: transparent !important;\n }\n}\n'; 117 | 118 | var Editor = (function (_React$Component) { 119 | _inherits(Editor, _React$Component); 120 | 121 | function Editor() { 122 | var _ref; 123 | 124 | var _temp, _this, _ret; 125 | 126 | _classCallCheck(this, Editor); 127 | 128 | for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { 129 | args[_key] = arguments[_key]; 130 | } 131 | 132 | return ( 133 | (_ret = 134 | ((_temp = ((_this = _possibleConstructorReturn(this, (_ref = Editor.__proto__ || Object.getPrototypeOf(Editor)).call.apply(_ref, [this].concat(args)))), _this)), 135 | (_this.state = { 136 | capture: true, 137 | }), 138 | (_this._recordCurrentState = function () { 139 | var input = _this._input; 140 | 141 | if (!input) return; 142 | 143 | // Save current state of the input 144 | var value = input.value, 145 | selectionStart = input.selectionStart, 146 | selectionEnd = input.selectionEnd; 147 | 148 | _this._recordChange({ 149 | value: value, 150 | selectionStart: selectionStart, 151 | selectionEnd: selectionEnd, 152 | }); 153 | }), 154 | (_this._getLines = function (text, position) { 155 | return text.substring(0, position).split('\n'); 156 | }), 157 | (_this._recordChange = function (record) { 158 | var overwrite = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; 159 | var _this$_history = _this._history, 160 | stack = _this$_history.stack, 161 | offset = _this$_history.offset; 162 | 163 | if (stack.length && offset > -1) { 164 | // When something updates, drop the redo operations 165 | _this._history.stack = stack.slice(0, offset + 1); 166 | 167 | // Limit the number of operations to 100 168 | var count = _this._history.stack.length; 169 | 170 | if (count > HISTORY_LIMIT) { 171 | var extras = count - HISTORY_LIMIT; 172 | 173 | _this._history.stack = stack.slice(extras, count); 174 | _this._history.offset = Math.max(_this._history.offset - extras, 0); 175 | } 176 | } 177 | 178 | var timestamp = Date.now(); 179 | 180 | if (overwrite) { 181 | var last = _this._history.stack[_this._history.offset]; 182 | 183 | if (last && timestamp - last.timestamp < HISTORY_TIME_GAP) { 184 | // A previous entry exists and was in short interval 185 | 186 | // Match the last word in the line 187 | var re = /[^a-z0-9]([a-z0-9]+)$/i; 188 | 189 | // Get the previous line 190 | var previous = _this._getLines(last.value, last.selectionStart).pop().match(re); 191 | 192 | // Get the current line 193 | var current = _this._getLines(record.value, record.selectionStart).pop().match(re); 194 | 195 | if (previous && current && current[1].startsWith(previous[1])) { 196 | // The last word of the previous line and current line match 197 | // Overwrite previous entry so that undo will remove whole word 198 | _this._history.stack[_this._history.offset] = _extends({}, record, { timestamp: timestamp }); 199 | 200 | return; 201 | } 202 | } 203 | } 204 | 205 | // Add the new operation to the stack 206 | _this._history.stack.push(_extends({}, record, { timestamp: timestamp })); 207 | _this._history.offset++; 208 | }), 209 | (_this._updateInput = function (record) { 210 | var input = _this._input; 211 | 212 | if (!input) return; 213 | 214 | // Update values and selection state 215 | input.value = record.value; 216 | input.selectionStart = record.selectionStart; 217 | input.selectionEnd = record.selectionEnd; 218 | 219 | _this.props.onValueChange(record.value); 220 | }), 221 | (_this._applyEdits = function (record) { 222 | // Save last selection state 223 | var input = _this._input; 224 | var last = _this._history.stack[_this._history.offset]; 225 | 226 | if (last && input) { 227 | _this._history.stack[_this._history.offset] = _extends({}, last, { 228 | selectionStart: input.selectionStart, 229 | selectionEnd: input.selectionEnd, 230 | }); 231 | } 232 | 233 | // Save the changes 234 | _this._recordChange(record); 235 | _this._updateInput(record); 236 | }), 237 | (_this._undoEdit = function () { 238 | var _this$_history2 = _this._history, 239 | stack = _this$_history2.stack, 240 | offset = _this$_history2.offset; 241 | 242 | // Get the previous edit 243 | 244 | var record = stack[offset - 1]; 245 | 246 | if (record) { 247 | // Apply the changes and update the offset 248 | _this._updateInput(record); 249 | _this._history.offset = Math.max(offset - 1, 0); 250 | } 251 | }), 252 | (_this._redoEdit = function () { 253 | var _this$_history3 = _this._history, 254 | stack = _this$_history3.stack, 255 | offset = _this$_history3.offset; 256 | 257 | // Get the next edit 258 | 259 | var record = stack[offset + 1]; 260 | 261 | if (record) { 262 | // Apply the changes and update the offset 263 | _this._updateInput(record); 264 | _this._history.offset = Math.min(offset + 1, stack.length - 1); 265 | } 266 | }), 267 | (_this._handleChange = function (e) { 268 | var _e$target2 = e.target, 269 | value = _e$target2.value, 270 | selectionStart = _e$target2.selectionStart, 271 | selectionEnd = _e$target2.selectionEnd; 272 | 273 | _this._recordChange( 274 | { 275 | value: value, 276 | selectionStart: selectionStart, 277 | selectionEnd: selectionEnd, 278 | }, 279 | true, 280 | ); 281 | 282 | _this.props.onValueChange(value); 283 | }), 284 | (_this._history = { 285 | stack: [], 286 | offset: -1, 287 | }), 288 | _temp)), 289 | _possibleConstructorReturn(_this, _ret) 290 | ); 291 | } 292 | 293 | _createClass(Editor, [ 294 | { 295 | key: 'componentDidMount', 296 | value: function componentDidMount() { 297 | this._recordCurrentState(); 298 | }, 299 | }, 300 | { 301 | key: 'render', 302 | value: function render() { 303 | var _this2 = this; 304 | 305 | var _props = this.props, 306 | value = _props.value, 307 | style = _props.style, 308 | padding = _props.padding, 309 | highlight = _props.highlight, 310 | textareaId = _props.textareaId, 311 | textareaClassName = _props.textareaClassName, 312 | autoFocus = _props.autoFocus, 313 | disabled = _props.disabled, 314 | form = _props.form, 315 | maxLength = _props.maxLength, 316 | minLength = _props.minLength, 317 | name = _props.name, 318 | placeholder = _props.placeholder, 319 | readOnly = _props.readOnly, 320 | required = _props.required, 321 | onClick = _props.onClick, 322 | onFocus = _props.onFocus, 323 | onBlur = _props.onBlur, 324 | onKeyUp = _props.onKeyUp, 325 | onKeyDown = _props.onKeyDown, 326 | onValueChange = _props.onValueChange, 327 | tabSize = _props.tabSize, 328 | insertSpaces = _props.insertSpaces, 329 | ignoreTabKey = _props.ignoreTabKey, 330 | preClassName = _props.preClassName, 331 | rest = _objectWithoutProperties(_props, [ 332 | 'value', 333 | 'style', 334 | 'padding', 335 | 'highlight', 336 | 'textareaId', 337 | 'textareaClassName', 338 | 'autoFocus', 339 | 'disabled', 340 | 'form', 341 | 'maxLength', 342 | 'minLength', 343 | 'name', 344 | 'placeholder', 345 | 'readOnly', 346 | 'required', 347 | 'onClick', 348 | 'onFocus', 349 | 'onBlur', 350 | 'onKeyUp', 351 | 'onKeyDown', 352 | 'onValueChange', 353 | 'tabSize', 354 | 'insertSpaces', 355 | 'ignoreTabKey', 356 | 'preClassName', 357 | ]); 358 | 359 | var contentStyle = { 360 | paddingTop: padding, 361 | paddingRight: padding, 362 | paddingBottom: padding, 363 | paddingLeft: padding, 364 | }; 365 | 366 | var highlighted = highlight(value); 367 | 368 | return React.createElement( 369 | 'div', 370 | _extends({}, rest, { style: _extends({}, styles.container, style) }), 371 | React.createElement('textarea', { 372 | ref: function ref(c) { 373 | return (_this2._input = c); 374 | }, 375 | style: _extends({}, styles.editor, styles.textarea, contentStyle), 376 | className: className + (textareaClassName ? ' ' + textareaClassName : ''), 377 | id: textareaId, 378 | value: value, 379 | onChange: this._handleChange, 380 | onClick: onClick, 381 | onKeyUp: onKeyUp, 382 | onFocus: onFocus, 383 | onBlur: onBlur, 384 | disabled: disabled, 385 | form: form, 386 | maxLength: maxLength, 387 | minLength: minLength, 388 | name: name, 389 | placeholder: placeholder, 390 | readOnly: readOnly, 391 | required: required, 392 | autoFocus: autoFocus, 393 | autoCapitalize: 'off', 394 | autoComplete: 'off', 395 | autoCorrect: 'off', 396 | spellCheck: false, 397 | 'data-gramm': false, 398 | }), 399 | React.createElement( 400 | 'pre', 401 | _extends( 402 | { 403 | className: preClassName, 404 | 'aria-hidden': 'true', 405 | style: _extends({}, styles.editor, styles.highlight, contentStyle), 406 | }, 407 | typeof highlighted === 'string' ? { dangerouslySetInnerHTML: { __html: highlighted + '
' } } : { children: highlighted }, 408 | ), 409 | ), 410 | React.createElement('style', { type: 'text/css', dangerouslySetInnerHTML: { __html: cssText } }), 411 | ); 412 | }, 413 | }, 414 | { 415 | key: 'session', 416 | get: function get() { 417 | return { 418 | history: this._history, 419 | }; 420 | }, 421 | set: function set(session) { 422 | this._history = session.history; 423 | }, 424 | }, 425 | ]); 426 | 427 | return Editor; 428 | })(React.Component); 429 | 430 | Editor.defaultProps = { 431 | tabSize: 2, 432 | insertSpaces: true, 433 | ignoreTabKey: false, 434 | padding: 0, 435 | }; 436 | exports.default = Editor; 437 | 438 | var styles = { 439 | container: { 440 | position: 'relative', 441 | textAlign: 'left', 442 | boxSizing: 'border-box', 443 | padding: 0, 444 | overflow: 'hidden', 445 | }, 446 | textarea: { 447 | position: 'absolute', 448 | top: 0, 449 | left: 0, 450 | height: '100%', 451 | width: '100%', 452 | resize: 'none', 453 | color: 'inherit', 454 | overflow: 'hidden', 455 | MozOsxFontSmoothing: 'grayscale', 456 | WebkitFontSmoothing: 'antialiased', 457 | WebkitTextFillColor: 'transparent', 458 | }, 459 | highlight: { 460 | position: 'relative', 461 | pointerEvents: 'none', 462 | }, 463 | editor: { 464 | margin: 0, 465 | border: 0, 466 | background: 'none', 467 | boxSizing: 'inherit', 468 | display: 'inherit', 469 | fontFamily: 'inherit', 470 | fontSize: 'inherit', 471 | fontStyle: 'inherit', 472 | fontVariantLigatures: 'inherit', 473 | fontWeight: 'inherit', 474 | letterSpacing: 'inherit', 475 | lineHeight: 'inherit', 476 | tabSize: 'inherit', 477 | textIndent: 'inherit', 478 | textRendering: 'inherit', 479 | textTransform: 'inherit', 480 | whiteSpace: 'pre-wrap', 481 | wordBreak: 'break-all', 482 | overflowWrap: 'break-word', 483 | }, 484 | }; 485 | //# sourceMappingURL=index.js.map 486 | -------------------------------------------------------------------------------- /src/json5-editor/constant/index.ts: -------------------------------------------------------------------------------- 1 | export const startList: (Prism.TokenStream | undefined)[] = ['{', '[', '(']; 2 | export const endList: (Prism.TokenStream | undefined)[] = ['}', ']', ')']; 3 | // include undefined just for nonstandard JSON, even though it's not a valid primitive types 4 | export const keywords = ['true', 'false', 'null', 'undefined']; 5 | export const arrayCollapse = '[┉]'; 6 | export const objectCollapse = '{┉}'; 7 | export const defaultConfig = { 8 | type: 'whole' as const, 9 | propertyQuotes: 'preserve' as const, 10 | }; 11 | -------------------------------------------------------------------------------- /src/json5-editor/hooks/useUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const useUpdateEffect: typeof useEffect = (effect, deps) => { 4 | const isMounted = useRef(false); 5 | 6 | useEffect(() => { 7 | if (!isMounted.current) { 8 | isMounted.current = true; 9 | } else { 10 | return effect(); 11 | } 12 | }, deps); 13 | }; 14 | 15 | export default useUpdateEffect; 16 | -------------------------------------------------------------------------------- /src/json5-editor/hooks/useWidth.ts: -------------------------------------------------------------------------------- 1 | import { useState, useLayoutEffect } from 'react'; 2 | 3 | function useWidth(target: React.RefObject): number { 4 | const [state, setState] = useState(() => { 5 | const el = target.current; 6 | return (el || {}).clientWidth || 0; 7 | }); 8 | 9 | useLayoutEffect(() => { 10 | const el = target.current; 11 | if (!el) { 12 | return () => {}; 13 | } 14 | 15 | const resizeObserver = new ResizeObserver((entries) => { 16 | entries.forEach((entry) => { 17 | setState(entry.target.clientWidth || 0); 18 | }); 19 | }); 20 | 21 | resizeObserver.observe(el as HTMLElement); 22 | return () => { 23 | resizeObserver.disconnect(); 24 | }; 25 | }, [target]); 26 | 27 | return state; 28 | } 29 | 30 | export default useWidth; 31 | -------------------------------------------------------------------------------- /src/json5-editor/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | group: 3 | title: Demo 4 | --- 5 | 6 | ## Demo 7 | 8 | ```tsx 9 | import React from 'react'; 10 | import { Editor } from 'json5-editor'; 11 | 12 | export default () => ; 13 | ``` 14 | 15 | ```tsx 16 | /** 17 | * debug: true 18 | * title: work with antd form 19 | */ 20 | 21 | import React, { useState } from 'react'; 22 | import { Form } from 'antd'; 23 | import { Editor } from 'json5-editor'; 24 | 25 | export default () => { 26 | const [form] = Form.useForm(); 27 | const [key, setKey] = useState(0); 28 | return ( 29 |
56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | ); 64 | }; 65 | ``` 66 | 67 | ```tsx 68 | /** 69 | * debug: true 70 | * title: multiple editor 71 | */ 72 | 73 | import React, { useState } from 'react'; 74 | import { Editor } from 'json5-editor'; 75 | 76 | export default () => { 77 | const [value, setValue] = useState(`{ 78 | success: true, 79 | data: { 80 | user: { 81 | name: "Troy", 82 | age: 25, // born in 1996 83 | key: "value", 84 | phoneNumber: 13800000000 // 11 digits in number format 85 | }, 86 | }, 87 | success: false 88 | }`); 89 | return ( 90 | <> 91 | 92 | 93 | 94 | 95 | ); 96 | }; 97 | ``` 98 | -------------------------------------------------------------------------------- /src/json5-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, memo, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; 2 | import { tokenize } from 'prismjs/components/prism-core'; 3 | import classNames from 'classnames'; 4 | // TODO: need fork react-simple-code-editor's code to disable auto complete 5 | // @ts-expect-error 6 | import Editor from './Editor'; 7 | import { endList, startList } from './constant'; 8 | import { 9 | getLinesByPos, 10 | insertText, 11 | generateWhiteSpace, 12 | getTokensOfCurrentLine, 13 | getCurrentTokenIndex, 14 | isToken, 15 | getTokenContent, 16 | tokenContentEquals, 17 | getLengthOfToken, 18 | markErrorToken, 19 | } from './utils/autoComplete'; 20 | import { activePairs, clearPairs } from './utils/match'; 21 | import copy from 'copy-to-clipboard'; 22 | import { lex, afterTokenizeHook, tokenStreamToHtml } from './utils/prism'; 23 | import { addLineNumber, getCollapsedContent } from './utils/lineNumber'; 24 | import { Traverse, ValidateError } from './utils/format'; 25 | import './style.less'; 26 | import useWidth from './hooks/useWidth'; 27 | import useUpdateEffect from './hooks/useUpdateEffect'; 28 | export interface Props { 29 | initialValue?: string; 30 | value?: string; 31 | onChange?: (v: string) => void; 32 | placeholder?: string; 33 | style?: React.CSSProperties; 34 | className?: string; 35 | disabled?: boolean; 36 | readOnly?: boolean; 37 | showLineNumber?: boolean; 38 | formatConfig?: Omit; 39 | } 40 | 41 | export interface RefProps { 42 | editorRef: HTMLTextAreaElement | null; 43 | preRef: HTMLPreElement | null; 44 | value: string; 45 | onChange: React.Dispatch>; 46 | format: () => void; 47 | } 48 | 49 | export type RootEnv = Prism.Environment & { tokens: (Prism.Token | string)[]; code: string; element: HTMLDivElement }; 50 | export type FormatConfig = { 51 | type?: 'whole' | 'segment'; 52 | propertyQuotes?: 'single' | 'double' | 'preserve' | 'preserve'; 53 | }; 54 | 55 | export const formatJSON5 = (code: string, config?: FormatConfig) => { 56 | const tokens = tokenize(code, lex); 57 | const traverse = new Traverse(tokens, config); 58 | traverse.format(); 59 | return traverse.getString(); 60 | }; 61 | 62 | export default memo( 63 | forwardRef((props: Props, ref: Ref) => { 64 | const textAreaRef = useRef(null); 65 | const preElementRef = useRef(null); 66 | const [formatError, setFormatError] = useState(null); 67 | const [code = '', _setCode] = useState(props.value || props.initialValue || ''); 68 | const shouldForbiddenEdit = props.disabled || props.readOnly || ('value' in props && !('onChange' in props)); 69 | const tokensRef = useRef<(Prism.Token | string)[]>([]); 70 | const tokenLinesRef = useRef<(Prism.Token | string)[][]>([]); 71 | const previousKeyboardEvent = useRef(null); 72 | const container = useRef(null); 73 | const width = useWidth(container); 74 | const collapsedList = useRef([]); 75 | const lock = useRef(false); 76 | const onChangeRef = useRef(props.onChange); 77 | onChangeRef.current = props.onChange; 78 | const editStarted = useRef(false); 79 | const isFocused = useRef(false); 80 | const formatConfig = useRef(props.formatConfig); 81 | formatConfig.current = props.formatConfig; 82 | 83 | const codeRef = useRef(code); 84 | codeRef.current = code; 85 | 86 | const getExpandedCode = useCallback((code: string = codeRef.current) => { 87 | let newCode = code.replace(/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g, match => { 88 | const count = match.length - 3; 89 | return collapsedList.current[count] || match; 90 | }); 91 | if (/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g.test(newCode)) { 92 | newCode = getExpandedCode(newCode); 93 | } 94 | 95 | const fullTokens = tokenize(newCode, lex); 96 | const fullTraverse = new Traverse(fullTokens, formatConfig.current); 97 | 98 | return fullTraverse.format(); 99 | }, []); 100 | 101 | const validateFullCode = () => { 102 | try { 103 | const fullTokens = tokenize(getExpandedCode(), lex); 104 | const fullTraverse = new Traverse(fullTokens, formatConfig.current); 105 | fullTraverse.validate({ mode: 'loose' }); 106 | setFormatError(null); 107 | } catch (e) { 108 | setFormatError(e as ValidateError); 109 | if (process.env.NODE_ENV === 'development') { 110 | console.log(e); 111 | } 112 | } 113 | }; 114 | 115 | const setCode: React.Dispatch> = val => { 116 | _setCode(val); 117 | editStarted.current = true; 118 | }; 119 | 120 | useEffect(() => { 121 | if (editStarted.current) { 122 | if (props.onChange) { 123 | props.onChange(getExpandedCode()); 124 | } 125 | editStarted.current = false; 126 | } 127 | }, [code]); 128 | 129 | useUpdateEffect(() => { 130 | if (!isFocused.current) { 131 | _setCode(props.value || ''); 132 | editStarted.current = false; 133 | } 134 | }, [props.value]); 135 | 136 | const onCollapse = (newCode: string, collapsedCode: string, uuid: number) => { 137 | collapsedList.current[uuid] = collapsedCode; 138 | const tokens = tokenize(newCode, lex); 139 | const traverse = new Traverse(tokens, formatConfig.current); 140 | _setCode(traverse.format()); 141 | }; 142 | 143 | const onExpand = (uuid: number) => { 144 | const newCode = codeRef.current.replace(/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g, match => { 145 | const count = match.length - 3; 146 | return count === uuid ? collapsedList.current[uuid] : match; 147 | }); 148 | 149 | const tokens = tokenize(newCode, lex); 150 | const traverse = new Traverse(tokens, formatConfig.current); 151 | 152 | _setCode(traverse.format()); 153 | validateFullCode(); 154 | }; 155 | 156 | useEffect(() => { 157 | if (shouldForbiddenEdit) { 158 | textAreaRef.current!.style.pointerEvents = 'none'; 159 | textAreaRef.current!.style.userSelect = 'none'; 160 | textAreaRef.current?.setAttribute('tabIndex', '-1'); 161 | 162 | preElementRef.current!.style.removeProperty('user-select'); 163 | preElementRef.current!.style.removeProperty('pointer-events'); 164 | } else { 165 | preElementRef.current!.style.userSelect = 'none'; 166 | preElementRef.current!.style.pointerEvents = 'none'; 167 | 168 | textAreaRef.current!.style.removeProperty('pointer-events'); 169 | textAreaRef.current!.style.removeProperty('user-select'); 170 | textAreaRef.current?.removeAttribute('tabIndex'); 171 | } 172 | }, [shouldForbiddenEdit]); 173 | 174 | const format = useCallback(() => { 175 | textAreaRef.current?.dispatchEvent(new Event('blur')); 176 | }, []); 177 | 178 | useImperativeHandle( 179 | ref, 180 | () => { 181 | const ret = { 182 | editorRef: textAreaRef.current, 183 | preRef: preElementRef.current, 184 | value: '', 185 | onChange: setCode, 186 | format, 187 | }; 188 | Object.defineProperty(ret, 'value', { 189 | get: getExpandedCode, 190 | }); 191 | return ret; 192 | }, 193 | [], 194 | ); 195 | 196 | useEffect(() => { 197 | const textArea = textAreaRef.current!; 198 | 199 | const onPaste = (e: ClipboardEvent) => { 200 | e.preventDefault(); 201 | const clipboardData = e.clipboardData; 202 | let pastedData = clipboardData?.getData('Text') || ''; 203 | const startPos = textArea?.selectionStart || 0; 204 | const { leadingWhiteSpace } = getLinesByPos(codeRef.current, startPos); 205 | pastedData = pastedData 206 | .split('\n') 207 | .map((line, index) => { 208 | if (index === 0) { 209 | return line; 210 | } 211 | return `${generateWhiteSpace(leadingWhiteSpace)}${line}`; 212 | }) 213 | .join('\n'); 214 | insertText(pastedData); 215 | }; 216 | 217 | const onCopy = (e: ClipboardEvent) => { 218 | const startPos = textArea?.selectionStart || 0; 219 | const endPos = textArea?.selectionEnd || 0; 220 | const selected = codeRef.current.slice(startPos, endPos); 221 | const content = getCollapsedContent(collapsedList.current, selected); 222 | if (/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g.test(selected)) { 223 | e.preventDefault(); 224 | copy(new Traverse(tokenize(content, lex), formatConfig.current).format()); 225 | } 226 | }; 227 | 228 | const onCut = (e: ClipboardEvent) => { 229 | const startPos = textArea?.selectionStart || 0; 230 | const endPos = textArea?.selectionEnd || 0; 231 | const newText = codeRef.current.slice(0, startPos).concat(codeRef.current.slice(endPos)); 232 | const selected = codeRef.current.slice(startPos, endPos); 233 | const content = getCollapsedContent(collapsedList.current, selected); 234 | if (/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g.test(selected)) { 235 | e.preventDefault(); 236 | copy(new Traverse(tokenize(content, lex), formatConfig.current).format()); 237 | setCode(newText); 238 | textArea?.setSelectionRange(startPos, startPos); 239 | } 240 | }; 241 | 242 | const onMouseDown = () => { 243 | lock.current = true; 244 | }; 245 | 246 | const onMouseUp = () => { 247 | lock.current = false; 248 | }; 249 | 250 | // special char key down 251 | const keyDownHandler = (ev: KeyboardEvent) => { 252 | if (lock.current) { 253 | ev.preventDefault(); 254 | return; 255 | } 256 | 257 | const textArea = textAreaRef.current!; 258 | const startPos = textArea?.selectionStart || 0; 259 | const endPos = textArea?.selectionEnd || 0; 260 | if (ev.code === 'Backspace') { 261 | const index = getCurrentTokenIndex(tokensRef.current, startPos); 262 | const currentToken = tokensRef.current[index]; 263 | if (isToken(currentToken) && currentToken.type === 'collapse') { 264 | ev.preventDefault(); 265 | const tokens = getTokensOfCurrentLine(tokensRef.current, startPos); 266 | const collapse = tokens.find(tok => isToken(tok) && tok.type === 'collapse'); 267 | const uuid = (collapse?.length || 3) - 3; 268 | onExpand(uuid); 269 | textArea.setSelectionRange(startPos - collapsedList.current.length, endPos - collapsedList.current.length); 270 | } 271 | } 272 | previousKeyboardEvent.current = ev; 273 | }; 274 | // format on blur 275 | const blurHandler = (ev: FocusEvent) => { 276 | isFocused.current = false; 277 | clearPairs(preElementRef.current!); 278 | const prevTokens = tokensRef.current; 279 | const traverse = new Traverse(prevTokens, formatConfig.current); 280 | traverse.format(); 281 | const str = traverse.getString(); 282 | setCode(str); 283 | validateFullCode(); 284 | }; 285 | 286 | // highlight active braces 287 | const cursorChangeHanlder = () => { 288 | let startPos = textArea?.selectionStart || 0; 289 | let endPos = textArea?.selectionEnd || 0; 290 | lock.current = false; 291 | requestAnimationFrame(() => { 292 | startPos = textArea?.selectionStart || 0; 293 | endPos = textArea?.selectionEnd || 0; 294 | if (codeRef.current[startPos] === '┉' && codeRef.current[endPos] === '┉') { 295 | const tokens = getTokensOfCurrentLine(tokensRef.current, startPos); 296 | const collapse = tokens.find(tok => isToken(tok) && tok.type === 'collapse'); 297 | const uuid = (collapse?.length || 3) - 3; 298 | onExpand(uuid); 299 | textArea.setSelectionRange(startPos, endPos); 300 | } 301 | if (['}', ']'].includes(codeRef.current[startPos]) && ['}', ']'].includes(codeRef.current[endPos]) && codeRef.current[startPos - 1] === '┉') { 302 | const tokens = getTokensOfCurrentLine(tokensRef.current, startPos); 303 | const collapse = tokens.find(tok => isToken(tok) && tok.type === 'collapse'); 304 | const uuid = (collapse?.length || 3) - 3; 305 | onExpand(uuid); 306 | textArea.setSelectionRange(startPos, endPos); 307 | } 308 | 309 | if (startPos !== endPos) { 310 | const startIndex = getCurrentTokenIndex(tokensRef.current, startPos); 311 | const endIndex = getCurrentTokenIndex(tokensRef.current, endPos); 312 | const startToken = tokensRef.current[startIndex]; 313 | const endToken = tokensRef.current[endIndex]; 314 | if ((isToken(startToken) && startToken.type === 'collapse') || (isToken(endToken) && endToken.type === 'collapse')) { 315 | if (endPos - startPos >= endToken.length && !['}', ']', '┉'].includes(codeRef.current[endPos])) { 316 | return; 317 | } 318 | const collapse = isToken(startToken) && startToken.type === 'collapse' ? startToken : endToken; 319 | const uuid = (collapse?.length || 3) - 3; 320 | onExpand(uuid); 321 | textArea.setSelectionRange(startPos, startPos); 322 | } 323 | } 324 | }); 325 | 326 | clearPairs(preElementRef.current!); 327 | if (Math.abs(startPos - endPos) > 1) { 328 | return; 329 | } 330 | if ( 331 | codeRef.current 332 | .slice(startPos) 333 | .split('') 334 | .filter(ele => ele === '"').length % 335 | 2 === 336 | 1 337 | ) { 338 | return; 339 | } 340 | if ( 341 | codeRef.current 342 | .slice(startPos) 343 | .split('') 344 | .filter(ele => ele === "'").length % 345 | 2 === 346 | 1 347 | ) { 348 | return; 349 | } 350 | if (startList.includes(codeRef.current[startPos])) { 351 | activePairs(preElementRef.current!, startPos); 352 | } else if (startList.includes(codeRef.current[startPos - 1])) { 353 | activePairs(preElementRef.current!, startPos - 1); 354 | } else if (endList.includes(codeRef.current[endPos - 1])) { 355 | activePairs(preElementRef.current!, endPos - 1); 356 | } else if (endList.includes(codeRef.current[endPos])) { 357 | activePairs(preElementRef.current!, endPos); 358 | } 359 | }; 360 | 361 | const focusHandler = () => { 362 | isFocused.current = true; 363 | setFormatError(null); 364 | }; 365 | 366 | textArea?.addEventListener('paste', onPaste); 367 | textArea?.addEventListener('copy', onCopy); 368 | textArea?.addEventListener('cut', onCut); 369 | textArea?.addEventListener('mousedown', onMouseDown); 370 | textArea?.addEventListener('mouseup', onMouseUp); 371 | textArea?.addEventListener('keydown', keyDownHandler); 372 | textArea?.addEventListener('blur', blurHandler); 373 | textArea?.addEventListener('focus', focusHandler); 374 | textArea?.addEventListener('select', cursorChangeHanlder); 375 | textArea?.addEventListener('keyup', cursorChangeHanlder); 376 | textArea?.addEventListener('click', cursorChangeHanlder); 377 | return () => { 378 | // 卸载 hook 379 | textArea?.removeEventListener('paste', onPaste); 380 | textArea?.removeEventListener('copy', onCopy); 381 | textArea?.removeEventListener('cut', onCut); 382 | textArea?.removeEventListener('mousedown', onMouseDown); 383 | textArea?.removeEventListener('mouseup', onMouseUp); 384 | textArea?.removeEventListener('keydown', keyDownHandler); 385 | textArea?.removeEventListener('blur', blurHandler); 386 | textArea?.removeEventListener('focus', focusHandler); 387 | textArea?.removeEventListener('select', cursorChangeHanlder); 388 | textArea?.removeEventListener('keyup', cursorChangeHanlder); 389 | textArea?.removeEventListener('click', cursorChangeHanlder); 390 | }; 391 | }, []); 392 | 393 | const autoFill = (env: RootEnv) => { 394 | const textArea = textAreaRef.current!; 395 | const startPos = textArea?.selectionStart || 0; 396 | const endPos = textArea?.selectionEnd || 0; 397 | if (startPos !== endPos) { 398 | return; 399 | } 400 | const ev = previousKeyboardEvent.current; 401 | previousKeyboardEvent.current = null; 402 | const tokenIndex = getCurrentTokenIndex(env.tokens, startPos); 403 | const current = env.tokens[tokenIndex]; 404 | 405 | outer: switch (true) { 406 | case ev?.code === 'Enter' && !ev?.isComposing: { 407 | const pairs = [ 408 | ['{', '}'], 409 | ['[', ']'], 410 | ['(', ')'], 411 | ]; 412 | inner: for (let [start, end] of pairs) { 413 | let previousList = getTokensOfCurrentLine(env.tokens, startPos - 1); 414 | previousList = previousList.filter(tok => getTokenContent(tok).trim() && isToken(tok) && tok.type !== 'comment'); 415 | const lastToken = previousList.pop(); 416 | if (tokenContentEquals(lastToken, start)) { 417 | requestAnimationFrame(() => { 418 | const { leadingWhiteSpace } = getLinesByPos(codeRef.current, startPos); 419 | const codeArray = codeRef.current.split(''); 420 | // start count !== end count, then append 421 | const needFill = codeArray.filter(ele => ele === start).length !== codeArray.filter(ele => ele === end).length; 422 | if (needFill) { 423 | insertText(`${generateWhiteSpace(leadingWhiteSpace + 2)}\n${generateWhiteSpace(leadingWhiteSpace)}${end}`); 424 | textArea?.setSelectionRange(startPos + leadingWhiteSpace + 2, startPos + leadingWhiteSpace + 2); 425 | } else { 426 | const currentStr = codeRef.current.split('')[startPos]; 427 | const nextStr = codeRef.current.split('')[startPos + 1]; 428 | if (currentStr === end && [undefined, '\n'].includes(nextStr)) { 429 | insertText(`${generateWhiteSpace(leadingWhiteSpace + 2)}\n${generateWhiteSpace(leadingWhiteSpace)}`); 430 | textArea?.setSelectionRange(textArea?.selectionStart - 1 - leadingWhiteSpace, textArea?.selectionStart - 1 - leadingWhiteSpace); 431 | } else { 432 | insertText(`${generateWhiteSpace(leadingWhiteSpace + 2)}`); 433 | } 434 | } 435 | }); 436 | break outer; 437 | } 438 | } 439 | 440 | // general case, add same indent space as previous line 441 | requestAnimationFrame(() => { 442 | const { leadingWhiteSpace } = getLinesByPos(codeRef.current, startPos); 443 | const fullList = getTokensOfCurrentLine(env.tokens, startPos - 1); 444 | 445 | const tokenList = fullList.filter(ele => isToken(ele) && ele.type !== 'comment' && getTokenContent(ele).trim()); 446 | const whiteSpace = generateWhiteSpace(leadingWhiteSpace); 447 | 448 | if (tokenList.length === 0) { 449 | insertText(whiteSpace); 450 | return; 451 | } 452 | 453 | const fullListLength = getLengthOfToken(fullList); 454 | const formatted = new Traverse(fullList, { ...formatConfig.current, type: 'segment' }).format(); 455 | 456 | textArea?.setSelectionRange(startPos - fullListLength - 1, startPos); 457 | 458 | const lines = formatted.split('\n'); 459 | const insert = lines.map(line => `${whiteSpace}${line}`).join('\n'); 460 | insertText(`${insert}\n${whiteSpace}`); 461 | }); 462 | break; 463 | } 464 | case ev?.key === ':' && tokenContentEquals(current, ':'): { 465 | requestAnimationFrame(() => { 466 | const line = getTokensOfCurrentLine(tokensRef.current, startPos).filter(tok => getTokenContent(tok).trim()); 467 | if (line.length === 2 && isToken(line[0]) && line[0].type === 'property') { 468 | insertText(' '); 469 | } 470 | return; 471 | }); 472 | break; 473 | } 474 | case ev?.key === '/' && isToken(current) && current.type === 'comment' && tokenContentEquals(current, '//'): { 475 | requestAnimationFrame(() => { 476 | let { leadingWhiteSpace } = getLinesByPos(codeRef.current, startPos); 477 | const fullList = getTokensOfCurrentLine(env.tokens, startPos); 478 | const fullListLength = getLengthOfToken(fullList); 479 | const previousList = getTokensOfCurrentLine(env.tokens, startPos - fullListLength - 1); 480 | leadingWhiteSpace = previousList.some(tok => isToken(tok) && tokenContentEquals(tok, '{')) && startPos !== fullListLength ? leadingWhiteSpace + 2 : leadingWhiteSpace; 481 | const formatted = new Traverse(fullList, { ...formatConfig.current, type: 'segment' }).format(); 482 | textArea?.setSelectionRange(startPos - fullListLength, startPos); 483 | const whiteSpace = generateWhiteSpace(leadingWhiteSpace); 484 | insertText(`${whiteSpace}${formatted} `); 485 | }); 486 | break; 487 | } 488 | case ev?.key === '|' && isToken(current) && tokenContentEquals(current, '|'): { 489 | requestAnimationFrame(() => { 490 | textArea?.setSelectionRange(startPos - 1, startPos); 491 | insertText(' | '); 492 | }); 493 | break; 494 | } 495 | default: { 496 | // do nothing, your code is perfect and has nothing to format 497 | } 498 | } 499 | 500 | if (props.showLineNumber) { 501 | setTimeout(() => { 502 | addLineNumber( 503 | { 504 | ...env, 505 | code: codeRef.current!, 506 | collapsedList: collapsedList.current, 507 | tokens: tokensRef.current!, 508 | }, 509 | onCollapse, 510 | onExpand, 511 | ); 512 | }, 32); 513 | } 514 | }; 515 | 516 | const highlight = (code: string) => { 517 | const env: RootEnv = { 518 | code, 519 | collapsedList: collapsedList.current, 520 | grammar: lex, 521 | language: 'json5', 522 | tokens: [], 523 | element: container.current!, 524 | error: formatError, 525 | }; 526 | env.tokens = tokenize(code, lex); 527 | afterTokenizeHook(env); 528 | env.tokenLines = markErrorToken(env.tokens, formatError); 529 | tokenLinesRef.current = env.tokenLines; 530 | env.fullTokens = getExpandedCode(); 531 | const htmlString = tokenStreamToHtml(env.tokens, env.language!); 532 | autoFill(env); 533 | if (props.showLineNumber) { 534 | addLineNumber(env, onCollapse, onExpand); 535 | } 536 | tokensRef.current = env.tokens; 537 | return htmlString; 538 | }; 539 | 540 | useEffect(() => { 541 | highlight(codeRef.current); 542 | }, [width]); 543 | 544 | return ( 545 |
550 | {props.showLineNumber &&
} 551 | { 553 | textAreaRef.current = r?._input; 554 | preElementRef.current = textAreaRef.current?.nextElementSibling as HTMLPreElement; 555 | }} 556 | value={code} 557 | disabled={shouldForbiddenEdit} 558 | placeholder={props.placeholder} 559 | onValueChange={setCode} 560 | highlight={(code: string) => { 561 | return highlight(code); 562 | }} 563 | padding={8} 564 | style={{ 565 | flex: 1, 566 | height: '100%', 567 | fontFamily: 'SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace', 568 | fontSize: 14, 569 | lineHeight: 1.5, 570 | ...props.style, 571 | }} 572 | /> 573 |
574 | ); 575 | }), 576 | ); 577 | -------------------------------------------------------------------------------- /src/json5-editor/style.less: -------------------------------------------------------------------------------- 1 | @namespace: ~'json5-editor-wrapper'; 2 | 3 | .@{namespace} { 4 | min-height: 200px; 5 | height: 100%; 6 | cursor: text; 7 | display: flex; 8 | 9 | div, 10 | textarea { 11 | outline: none; 12 | } 13 | textarea, 14 | pre { 15 | min-height: 200px; 16 | } 17 | 18 | // line numbers 19 | .line-numbers-rows { 20 | box-sizing: border-box; 21 | font-size: 14px; 22 | letter-spacing: -1px; 23 | border-right: 1px solid #e8e8e8; 24 | padding: 8px 0; 25 | user-select: none; 26 | font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; 27 | height: fit-content; 28 | background-color: rgba(0, 0, 0, 0.03); 29 | min-height: 200px; 30 | color: #999; 31 | min-width: 38px; 32 | line-height: 1; 33 | 34 | & > .errorLine { 35 | background: rgba(255, 77, 79, 0.9); 36 | color: #ffffff; 37 | 38 | & > .errorMessage { 39 | display: none; 40 | } 41 | 42 | &:hover > .errorMessage { 43 | display: inline-block; 44 | width: 0; 45 | max-width: 0; 46 | margin: 0; 47 | overflow: visible; 48 | transform: translate(8px, 1px); 49 | z-index: 1; 50 | 51 | & > span { 52 | z-index: 1; 53 | padding: 4px 8px; 54 | background: #57606f; 55 | word-break: break-all; 56 | white-space: pre; 57 | position: relative; 58 | overflow: visible; 59 | } 60 | } 61 | } 62 | 63 | & > span { 64 | padding: 0 8px; 65 | height: 21px; 66 | display: flex; 67 | align-items: flex-start; 68 | justify-content: space-between; 69 | cursor: pointer; 70 | & > code { 71 | position: relative; 72 | top: 2px; 73 | } 74 | & > span { 75 | position: relative; 76 | top: 1px; 77 | margin-left: 6px; 78 | } 79 | } 80 | } 81 | 82 | border: 1px solid #e8e8e8; 83 | &.@{namespace}-has-error { 84 | border: 1px solid rgba(255, 77, 79, 0.5); 85 | } 86 | 87 | &.@{namespace}-disabled { 88 | cursor: not-allowed; 89 | background-color: #f5f5f5; 90 | border-color: #e8e8e8; 91 | &:hover { 92 | border-color: #e8e8e8; 93 | } 94 | } 95 | 96 | overflow: auto; 97 | transition: all 0.3s; 98 | 99 | &:hover { 100 | border: 1px solid rgba(64, 169, 255, 0.5); 101 | } 102 | &.@{namespace}-has-error:hover { 103 | border: 1px solid rgb(255, 77, 79); 104 | } 105 | &:focus-within { 106 | border-color: #40a9ff; 107 | border-right-width: 1px !important; 108 | outline: 0; 109 | box-shadow: 0 0 0 2px rgb(24 144 255 / 20%); 110 | } 111 | 112 | .token.error { 113 | background-color: rgba(0, 0, 0, 0.1); 114 | } 115 | .token.punctuation { 116 | color: #595959; 117 | } 118 | .token.punctuation.active { 119 | outline: 1px solid rgba(0, 0, 0, 0.25); 120 | } 121 | .token.indent { 122 | &::after { 123 | position: absolute; 124 | content: url("data:image/svg+xml;utf-8,"); 125 | color: rgba(0, 0, 0, 0.1); 126 | transform: translate(-9px, 0); 127 | } 128 | } 129 | .token.string { 130 | color: #690; 131 | } 132 | .token.comment { 133 | color: #6a737d; 134 | } 135 | .token.number, 136 | .token.boolean, 137 | .token.keyword { 138 | color: #905; 139 | } 140 | .token.property { 141 | color: #005cc5; 142 | } 143 | .token.property.exist-property { 144 | text-decoration: line-through; 145 | } 146 | pre { 147 | color: #005cc5; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/json5-editor/utils/autoComplete.ts: -------------------------------------------------------------------------------- 1 | import { ValidateError } from './format'; 2 | 3 | export const getLinesByPos = (code: string, startPos: number) => { 4 | const prefix = code.slice(0, startPos); 5 | const currentLineStart = prefix.lastIndexOf('\n') + 1; 6 | const previousLineStart = prefix.slice(0, currentLineStart - 1).lastIndexOf('\n') + 1; 7 | const currentLine = prefix.slice(currentLineStart); 8 | const previousLine = prefix.slice(previousLineStart, currentLineStart); 9 | const foundIndex = previousLine.split('').findIndex((ele) => ele !== ' '); 10 | const leadingWhiteSpace = foundIndex === -1 ? 0 : foundIndex; 11 | 12 | return { 13 | leadingWhiteSpace, 14 | currentLine, 15 | previousLine, 16 | }; 17 | }; 18 | 19 | export const generateWhiteSpace = (whitespace: number) => { 20 | return Array(Math.max(whitespace + 1, 1)).join(' '); 21 | }; 22 | 23 | export const insertText = (text: string) => { 24 | document.execCommand('insertText', false, text); 25 | }; 26 | 27 | export const getTokensOfCurrentLine = (tokens: (Prism.Token | string)[], cursorIndex: number) => { 28 | const tokenIndex = getCurrentTokenIndex(tokens, cursorIndex); 29 | let currentIndex = tokenIndex; 30 | const line: (Prism.Token | string)[] = []; 31 | while (currentIndex >= 0) { 32 | const current = tokens[currentIndex]; 33 | if (isToken(current) && current.type === 'linebreak') { 34 | break; 35 | } else { 36 | line.unshift(current); 37 | } 38 | currentIndex--; 39 | } 40 | return line; 41 | }; 42 | 43 | export const getCurrentTokenIndex = (tokens: (Prism.Token | string)[], cursorIndex: number) => { 44 | let remain = cursorIndex; 45 | let foundIndex = -1; 46 | for (let i = 0; i < tokens.length; i++) { 47 | const currentToken = tokens[i]; 48 | if (remain - currentToken.length > 0) { 49 | remain -= currentToken.length; 50 | } else { 51 | foundIndex = i; 52 | break; 53 | } 54 | } 55 | return foundIndex; 56 | }; 57 | 58 | export const isToken = (tok: string | Prism.Token | undefined | Prism.Token[]): tok is Prism.Token => { 59 | return Boolean(tok && typeof tok !== 'string'); 60 | }; 61 | 62 | export const getTokenContent = (tok: Prism.TokenStream | undefined): string => { 63 | if (Array.isArray(tok)) { 64 | return tok 65 | .map((t) => { 66 | return getTokenContent(t); 67 | }) 68 | .join(''); 69 | } 70 | if (isToken(tok)) { 71 | return getTokenContent(tok.content); 72 | } 73 | return tok || ''; 74 | }; 75 | 76 | export const tokenContentEquals = (tok: Prism.TokenStream | undefined, val: string): tok is string => { 77 | if (!tok) { 78 | return false; 79 | } 80 | if (tok === val) { 81 | return true; 82 | } 83 | if (Array.isArray(tok)) { 84 | return false; 85 | } 86 | if (typeof tok !== 'string') { 87 | return tokenContentEquals(tok.content, val); 88 | } 89 | return false; 90 | }; 91 | 92 | export const getLengthOfToken = (tok: Prism.TokenStream | undefined): number => { 93 | // let len = 0 94 | if (Array.isArray(tok)) { 95 | return tok.reduce((acc, ele) => { 96 | return acc + getLengthOfToken(ele); 97 | }, 0); 98 | } 99 | return tok?.length || 0; 100 | }; 101 | 102 | export const markErrorToken = (tok: (Prism.Token | string)[], formatError: ValidateError | null) => { 103 | // if (!formatError || !formatError.lineNo) { 104 | // return; 105 | // } 106 | const hasError = formatError && formatError.lineNo; 107 | if (!Array.isArray(tok) || tok.length === 0) { 108 | return Array.isArray(tok) ? [tok] : [[tok]]; 109 | } 110 | let lineNo = 0; 111 | let column = 0; 112 | let errorFound = false; 113 | const lines: Prism.Token[][] = []; 114 | 115 | for (let i = 0; i < tok?.length; i++) { 116 | const current = tok[i]; 117 | if (current) { 118 | if (tokenContentEquals(current, '\n')) { 119 | lineNo += 1; 120 | column = 0; 121 | } else { 122 | lines[lineNo] = [...(lines[lineNo] || []), current]; 123 | if (hasError && lineNo === Number(formatError?.lineNo) - 1) { 124 | column += getLengthOfToken(current); 125 | if (!errorFound && column >= Number(formatError?.columnNo) && isToken(current)) { 126 | (current as Prism.Environment).hasError = true; 127 | errorFound = true; 128 | // break; 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | return lines; 136 | }; 137 | -------------------------------------------------------------------------------- /src/json5-editor/utils/format.ts: -------------------------------------------------------------------------------- 1 | import type { Token, TokenStream } from 'prismjs'; 2 | import { FormatConfig } from '..'; 3 | import { startList, endList, defaultConfig } from '../constant'; 4 | 5 | type CookedToken = Token & { index: number; lineNo?: number; columnNo?: number }; 6 | 7 | export class ValidateError extends Error { 8 | public lineNo: string; 9 | public columnNo: string; 10 | public token: Token; 11 | private extraMessage: string; 12 | 13 | constructor({ token, message }: { token: CookedToken; message?: string }) { 14 | super(); 15 | this.lineNo = String(token.lineNo || 'unknown'); 16 | this.columnNo = String(token.columnNo || 'unknown'); 17 | this.token = token; 18 | this.extraMessage = message || ''; 19 | this.message = this.toString(); 20 | } 21 | toString() { 22 | return `Invalid JSON5: @ line: ${this.lineNo}, column: ${this.columnNo}, invalid token: ${this.token.content}${this.extraMessage ? `, ${this.extraMessage}` : ''}`; 23 | } 24 | } 25 | 26 | export class Traverse { 27 | private rawTokens: (Token | string)[]; 28 | private cookedTokens: Array = []; 29 | private lineNo = 0; 30 | private columnNo = 0; 31 | // 2d array of tokens in each line; 32 | private lines: Array> = []; 33 | private leadingSpace = 0; 34 | private currentIndex = 0; 35 | private output = ''; 36 | private indentSize = 2; 37 | private valueTypes = ['string', 'number', 'boolean', 'null', 'unknown', 'collapse']; 38 | private config: FormatConfig = defaultConfig; 39 | 40 | constructor(tokens: (Token | string)[], config?: FormatConfig) { 41 | if (config) { 42 | this.config = { ...defaultConfig, ...config }; 43 | } 44 | this.rawTokens = tokens; 45 | this.cookTokens(); 46 | } 47 | 48 | /** 49 | * transform raw tokens to intermediate tokens that can generate the formatted code 50 | */ 51 | protected cookTokens() { 52 | // transform unknown string to token, and then save raw tokens just in case we may need it. 53 | const tokens = this.rawTokens 54 | .map((ele) => { 55 | if (typeof ele === 'string') { 56 | const content = ele.trim(); 57 | return { 58 | content, 59 | length: content.length, 60 | type: 'unknown', 61 | } as Token; 62 | } 63 | if (ele.type === 'property') { 64 | const needWrap = typeof ele.content === 'string' && !ele.content.startsWith('"') && !ele.content.startsWith("'"); 65 | const needReplace = typeof ele.content === 'string' && ele.content.startsWith("'"); 66 | let newContent = ele.content as string; 67 | switch (this.config.propertyQuotes) { 68 | case 'double': { 69 | if (needWrap) { 70 | newContent = `"${newContent}"`; 71 | } 72 | if (needReplace) { 73 | newContent = `"${newContent.slice(1, newContent.length - 1)}"`; 74 | } 75 | return { ...ele, content: newContent, length: newContent.length }; 76 | } 77 | case 'single': { 78 | if (needWrap) { 79 | newContent = `'${newContent}'`; 80 | } 81 | if (needReplace) { 82 | newContent = `'${newContent.slice(1, newContent.length - 1)}'`; 83 | } 84 | return { ...ele, content: newContent, length: newContent.length }; 85 | } 86 | default: { 87 | return ele; 88 | } 89 | } 90 | } 91 | return ele; 92 | }) 93 | .filter((ele) => ele.length !== 0); 94 | 95 | // combine adjutant number and unknown token to string 96 | const raw: Token[] = []; 97 | let skipNext = false; 98 | tokens.forEach((tok, index) => { 99 | const next = tokens[index + 1]; 100 | if (tok.type === 'number' && next && next.type === 'unknown') { 101 | raw.push({ 102 | ...tok, 103 | type: 'string', 104 | content: `"${tok.content}${next.content}"`, 105 | length: tok.content.length + next.content.length + 2, 106 | }); 107 | skipNext = true; 108 | } else { 109 | if (skipNext) { 110 | skipNext = false; 111 | } else { 112 | raw.push(tok); 113 | } 114 | } 115 | }); 116 | 117 | // filter empty spaces and commas to do better format 118 | this.cookedTokens = raw 119 | .filter( 120 | (ele) => 121 | // filter out empty space 122 | !(ele.type === 'unknown' && this.resolveTokenContent(ele.content).trim() === '') && 123 | // consequential space will become indent 124 | (ele as Token).type !== 'indent' && 125 | (ele as Token).type !== 'leading' && 126 | // filter out commas, we will add it back later 127 | (ele as Token).content !== ',', 128 | ) 129 | // add index for each token 130 | .map((ele, index) => ({ ...ele, index })); 131 | } 132 | 133 | /** 134 | * check if this is an object property line 135 | * @param line tokens in that line 136 | * @returns whether this line is a valid object property 137 | */ 138 | protected isPropertyLine(line: Token[]) { 139 | return line[0]?.type === 'property' && line[1].content === ':'; 140 | } 141 | 142 | /** 143 | * generate leading spce of current line 144 | * @returns leading space string 145 | */ 146 | protected getLeadingSpace() { 147 | const space = Math.max(this.leadingSpace + 1, 1); 148 | return Array(space).join(' '); 149 | } 150 | 151 | /** 152 | * resolve possibly nested token content 153 | * @param content the TokenStream type of content 154 | * @returns the string type of content 155 | */ 156 | protected resolveTokenContent(content: TokenStream): string { 157 | if (typeof content === 'string') { 158 | return content; 159 | } 160 | if (Array.isArray(content)) { 161 | return content 162 | .map((ele) => { 163 | this.resolveTokenContent(ele); 164 | }) 165 | .join(''); 166 | } 167 | return this.resolveTokenContent(content.content); 168 | } 169 | 170 | /** 171 | * add tokens into their lines one by one 172 | */ 173 | protected addTokenToCurrentLine() { 174 | const currentToken = this.cookedTokens[this.currentIndex]; 175 | try { 176 | this.lines[this.lineNo].push(currentToken); 177 | } catch (e) { 178 | this.lines[this.lineNo] = [currentToken]; 179 | } 180 | } 181 | 182 | /** 183 | * start a new line with appropriate indent size 184 | * @param indent 185 | * - increase for 'add indent size', 186 | * - decrease for 'minus indent size', 187 | * - undefined for 'keep the same indent size' 188 | */ 189 | protected addNewLine(indent?: 'increase' | 'decrease') { 190 | if (indent === 'increase') { 191 | this.leadingSpace += this.indentSize; 192 | } else if (indent === 'decrease') { 193 | this.leadingSpace -= this.indentSize; 194 | } 195 | this.lineNo += 1; 196 | this.output += '\n'; 197 | // clear columnNo 198 | this.columnNo = this.leadingSpace; 199 | this.output += this.getLeadingSpace(); 200 | } 201 | 202 | /** 203 | * find the next token 204 | * @param skipWhitespace whether linebreaks count as tokens 205 | * @returns next token 206 | */ 207 | protected lookAhead(skipWhitespace?: boolean) { 208 | if (this.currentIndex >= this.cookedTokens.length - 1) { 209 | return undefined; 210 | } 211 | if (skipWhitespace) { 212 | return this.cookedTokens.slice(this.currentIndex + 1).find((tok) => { 213 | return tok.type !== 'linebreak'; 214 | }); 215 | } 216 | return this.cookedTokens[this.currentIndex + 1]; 217 | } 218 | 219 | /** 220 | * find the previous token 221 | * @param skipWhitespace whether linebreaks count as tokens 222 | * @returns previous token 223 | */ 224 | protected lookBehind(skipWhitespace?: boolean) { 225 | if (this.currentIndex <= 0) { 226 | return undefined; 227 | } 228 | if (skipWhitespace) { 229 | for (let i = this.currentIndex - 1; i >= 0; i--) { 230 | if (this.cookedTokens[i].type !== 'linebreak') { 231 | return this.cookedTokens[i]; 232 | } 233 | } 234 | return undefined; 235 | } 236 | return this.cookedTokens[this.currentIndex - 1]; 237 | } 238 | 239 | /** 240 | * look several tokens ahead 241 | * @param param 242 | * @returns array of tokens ahead 243 | */ 244 | protected lookAheadDeep(param: { skipWhitespace?: boolean; deepth: number }) { 245 | let remain = param.deepth || 1; 246 | const ret = []; 247 | const currentIndex = this.currentIndex; 248 | while (remain > 0) { 249 | const behind = this.lookAhead(param.skipWhitespace); 250 | if (behind) { 251 | ret.push(behind); 252 | this.currentIndex = behind?.index; 253 | remain -= 1; 254 | } else { 255 | remain = 0; 256 | } 257 | } 258 | this.currentIndex = currentIndex; 259 | return ret; 260 | } 261 | 262 | /** 263 | * look several tokens behind (currently not used) 264 | * @param param 265 | * @returns array of tokens behind 266 | */ 267 | protected lookBehindDeep(param: { skipWhitespace?: boolean; deepth: number }) { 268 | let remain = param.deepth || 1; 269 | const ret = []; 270 | const currentIndex = this.currentIndex; 271 | while (remain > 0) { 272 | const behind = this.lookBehind(param.skipWhitespace); 273 | if (behind) { 274 | ret.unshift(behind); 275 | this.currentIndex = behind?.index; 276 | remain -= 1; 277 | } else { 278 | remain = 0; 279 | } 280 | } 281 | this.currentIndex = currentIndex; 282 | return ret; 283 | } 284 | 285 | /** 286 | * get string value of previous line (currently not used) 287 | * @returns string value of previous line 288 | */ 289 | protected getOnelineBehind() { 290 | const endIndex = this.output.lastIndexOf('\n'); 291 | const lineStartIndex = this.output.lastIndexOf('\n', endIndex - 1); 292 | const startIndex = ~lineStartIndex ? lineStartIndex : 0; 293 | if (~endIndex && ~startIndex) { 294 | return this.output.slice(startIndex, endIndex); 295 | } 296 | return ''; 297 | } 298 | 299 | /** 300 | * only append comma if necessary 301 | */ 302 | protected appendComma() { 303 | // get 2 tokens ahead of comma 304 | const [ahead, ahead2] = this.lookAheadDeep({ deepth: 2, skipWhitespace: true }); 305 | if (ahead) { 306 | let next = this.resolveTokenContent(ahead.content); 307 | if (ahead.type === 'comment' && ahead2) { 308 | next = this.resolveTokenContent(ahead2.content); 309 | } 310 | // remove comma of last item in object and array. 311 | if (!['}', ']', '|', '(', ')'].includes(next)) { 312 | this.output += ','; 313 | } 314 | // add new line when start a new property/item in object/array, 315 | if (['{', '['].includes(ahead.content as string) || this.valueTypes.includes(ahead.type)) { 316 | this.addNewLine(); 317 | } 318 | } 319 | if (this.config.type === 'segment' && !ahead) { 320 | this.output += ','; 321 | } 322 | } 323 | 324 | /** 325 | * write columnNo and lineNo back to each token 326 | */ 327 | protected updateToken() { 328 | this.cookedTokens[this.currentIndex].lineNo = this.lineNo + 1; 329 | this.cookedTokens[this.currentIndex].columnNo = this.columnNo + 1; 330 | } 331 | 332 | /** 333 | * commit the string concatenation inside the function to correctly calculate appended string length 334 | * @param fn the action 335 | */ 336 | protected commitOutputTransaction(fn: () => void) { 337 | const tempLength = this.output.length; 338 | const tempLineNo = this.lineNo; 339 | this.updateToken(); 340 | fn(); 341 | const deltaLength = this.output.length - tempLength; 342 | if (this.lineNo === tempLineNo) { 343 | this.columnNo += deltaLength; 344 | } 345 | } 346 | 347 | /** 348 | * validate JSON5 349 | * @param param.mode 'strict' | 'loose' 350 | * default value 'strict'. 351 | * in 'loose' mode, values of "enum" type are supported, it is not a primative type in standard JSON5 format. 352 | */ 353 | public validate(param?: { mode: 'strict' | 'loose' }) { 354 | if (!this.output) { 355 | this.format(); 356 | } 357 | const { mode = 'strict' } = param || {}; 358 | const { lines } = this; 359 | let deepth = 0; 360 | let parentPunctuations: typeof lines[0] = []; 361 | outer: for (let i = 0; i < lines.length; i++) { 362 | const line = this.lines[i] || []; 363 | // valid line 364 | inner: for (let column = 0; column < line.length; column++) { 365 | const token = line[column]; 366 | // const columnNo = token + 367 | condition: switch (true) { 368 | case this.resolveTokenContent(token.content) === '{': { 369 | deepth += 1; 370 | const previousPunctuation = parentPunctuations[parentPunctuations.length - 1]; 371 | if (previousPunctuation?.content === '{' && this.isPropertyLine(line) && column === 2) { 372 | parentPunctuations.push(token); 373 | } else if (!previousPunctuation || (previousPunctuation.content === '[' && column === 0)) { 374 | parentPunctuations.push(token); 375 | } else { 376 | throw new ValidateError({ token }); 377 | } 378 | break; 379 | } 380 | case this.resolveTokenContent(token.content) === '}': { 381 | deepth -= 1; 382 | if (deepth < 0 && (i !== lines.length - 1 || column !== line.length - 1)) { 383 | throw new ValidateError({ token }); 384 | } 385 | const last = parentPunctuations.pop(); 386 | if (last?.content !== '{' || column !== 0) { 387 | throw new ValidateError({ token }); 388 | } 389 | break; 390 | } 391 | case this.resolveTokenContent(token.content) === '[': { 392 | deepth += 1; 393 | const previousPunctuation = parentPunctuations[parentPunctuations.length - 1]; 394 | if (previousPunctuation?.content === '{' && this.isPropertyLine(line) && column === 2) { 395 | parentPunctuations.push(token); 396 | } else if (!previousPunctuation || (previousPunctuation.content === '[' && column === 0)) { 397 | parentPunctuations.push(token); 398 | } else { 399 | throw new ValidateError({ token }); 400 | } 401 | break; 402 | } 403 | case this.resolveTokenContent(token.content) === ']': { 404 | deepth -= 1; 405 | if (deepth < 0 && (i !== lines.length - 1 || column !== line.length - 1)) { 406 | throw new ValidateError({ token }); 407 | } 408 | const last = parentPunctuations.pop(); 409 | if (last?.content !== '[' || column !== 0) { 410 | throw new ValidateError({ token }); 411 | } 412 | break; 413 | } 414 | case this.resolveTokenContent(token.content) === ':': { 415 | const previousToken = line[column - 1]; 416 | const nextToken = line[column + 1]; 417 | 418 | const previousTokenIsValid = previousToken?.type === 'property'; 419 | const nextTokenIsValid = Boolean(nextToken); 420 | 421 | if (!previousTokenIsValid || !nextTokenIsValid) { 422 | throw new ValidateError({ token }); 423 | } 424 | break; 425 | } 426 | case this.resolveTokenContent(token.content) === '|': { 427 | if (mode === 'strict') { 428 | throw new ValidateError({ token }); 429 | } 430 | const previousToken = line[column - 1]; 431 | const nextToken = line[column + 1]; 432 | const previousTokenIsValid = this.valueTypes.includes(previousToken?.type) || previousToken?.content === ')'; 433 | const nextTokenIsValid = this.valueTypes.includes(nextToken?.type) || nextToken?.content === '('; 434 | if (!previousToken || !previousTokenIsValid || !nextTokenIsValid) { 435 | throw new ValidateError({ token }); 436 | } 437 | break; 438 | } 439 | case this.resolveTokenContent(token.content) === '(': { 440 | if (mode === 'strict') { 441 | throw new ValidateError({ token }); 442 | } else { 443 | parentPunctuations.push(token); 444 | } 445 | break; 446 | } 447 | case this.resolveTokenContent(token.content) === ')': { 448 | if (mode === 'strict') { 449 | throw new ValidateError({ token }); 450 | } else { 451 | const last = parentPunctuations.pop(); 452 | if (last?.content !== '(' || last.lineNo !== token.lineNo) { 453 | throw new ValidateError({ token }); 454 | } 455 | } 456 | break; 457 | } 458 | case this.valueTypes.includes(token.type): { 459 | const previousPunctuation = parentPunctuations[parentPunctuations.length - 1]; 460 | const inObject = previousPunctuation?.content === '{' && this.isPropertyLine(line) && column === 2; 461 | const inArray = previousPunctuation?.content === '[' && column === 0; 462 | const pure = parentPunctuations.length === 0 && this.lines.length === 1 && column == 0; 463 | const inEnum = line[column - 1]?.content === '(' || line[column - 1]?.content === '|'; 464 | if (inEnum && mode === 'strict') { 465 | break; 466 | } 467 | if (!inObject && !inArray && !pure && !inEnum) { 468 | throw new ValidateError({ token }); 469 | } 470 | break; 471 | } 472 | case token.type === 'property': { 473 | break; 474 | } 475 | case token.type === 'comment': { 476 | break; 477 | } 478 | default: { 479 | throw new ValidateError({ token }); 480 | } 481 | } 482 | continue; 483 | } 484 | } 485 | if (parentPunctuations.length) { 486 | throw new ValidateError({ token: parentPunctuations[0] }); 487 | } 488 | } 489 | 490 | /** 491 | * do the JSON formatting with auto error recovery 492 | * @returns the formatted JSON string 493 | */ 494 | public format() { 495 | // the code is formatted 496 | if (this.output) { 497 | return this.output; 498 | } 499 | this.cookedTokens.forEach((token, index) => { 500 | this.currentIndex = index; 501 | switch (true) { 502 | case token.content === '{': { 503 | this.commitOutputTransaction(() => { 504 | this.addTokenToCurrentLine(); 505 | this.output += this.resolveTokenContent(token.content); 506 | }); 507 | this.addNewLine('increase'); 508 | break; 509 | } 510 | case token.content === '}': { 511 | const behind = this.lookBehind(true); 512 | if (behind) { 513 | this.addNewLine('decrease'); 514 | } 515 | this.commitOutputTransaction(() => { 516 | const content = this.resolveTokenContent(token.content); 517 | this.output += content; 518 | this.columnNo += content.length; 519 | this.addTokenToCurrentLine(); 520 | this.appendComma(); 521 | }); 522 | break; 523 | } 524 | case token.content === ':': { 525 | this.commitOutputTransaction(() => { 526 | this.output += ': '; 527 | this.addTokenToCurrentLine(); 528 | }); 529 | break; 530 | } 531 | case token.content === '[': { 532 | const ahead = this.lookAhead(true); 533 | this.commitOutputTransaction(() => { 534 | this.output += this.resolveTokenContent(token.content); 535 | this.addTokenToCurrentLine(); 536 | }); 537 | this.addNewLine('increase'); 538 | break; 539 | } 540 | case token.content === ']': { 541 | const behind = this.lookBehind(true); 542 | if (behind) { 543 | this.addNewLine('decrease'); 544 | } 545 | this.commitOutputTransaction(() => { 546 | this.output += this.resolveTokenContent(token.content); 547 | this.addTokenToCurrentLine(); 548 | this.appendComma(); 549 | }); 550 | break; 551 | } 552 | case token.content === '|': { 553 | this.commitOutputTransaction(() => { 554 | this.output += ` ${this.resolveTokenContent(token.content)} `; 555 | this.addTokenToCurrentLine(); 556 | }); 557 | break; 558 | } 559 | case token.content === '(': { 560 | this.commitOutputTransaction(() => { 561 | this.output += this.resolveTokenContent(token.content); 562 | this.addTokenToCurrentLine(); 563 | }); 564 | break; 565 | } 566 | // TODO: may have problem 567 | case token.type === 'comment': { 568 | const tokenBehind = this.lookBehind(); 569 | const nonWhiteSpaceTokenBehind = this.lookBehind(true); 570 | const nonWhiteSpaceTokenAhead = this.lookAhead(true); 571 | 572 | const shouldAddlineBreak = tokenBehind?.type === 'linebreak' && !startList.includes(nonWhiteSpaceTokenBehind?.content); 573 | 574 | if (shouldAddlineBreak) { 575 | this.addNewLine(); 576 | } 577 | 578 | this.commitOutputTransaction(() => { 579 | if (!shouldAddlineBreak && tokenBehind && !startList.includes(nonWhiteSpaceTokenBehind?.content)) { 580 | this.output += ' '; 581 | } 582 | this.addTokenToCurrentLine(); 583 | this.output += this.resolveTokenContent(token.content); 584 | }); 585 | 586 | if (nonWhiteSpaceTokenAhead && !endList.includes(nonWhiteSpaceTokenAhead.content) && nonWhiteSpaceTokenAhead.type !== 'comment') { 587 | this.addNewLine(); 588 | } 589 | break; 590 | } 591 | case token.type === 'property': { 592 | const nonWhiteSpaceTokenBehind = this.lookBehind(true); 593 | if (nonWhiteSpaceTokenBehind && nonWhiteSpaceTokenBehind?.content !== '{' && nonWhiteSpaceTokenBehind?.type !== 'comment') { 594 | this.addNewLine(); 595 | } 596 | 597 | this.commitOutputTransaction(() => { 598 | this.addTokenToCurrentLine(); 599 | this.output += this.resolveTokenContent(token.content); 600 | }); 601 | 602 | break; 603 | } 604 | case token.type === 'linebreak': { 605 | // do nothing, cause we handle line break ourselves, 606 | // however unlike commas, we need to keep linebreak token to correctly format comments 607 | break; 608 | } 609 | case token.type === 'unknown': { 610 | this.commitOutputTransaction(() => { 611 | this.addTokenToCurrentLine(); 612 | this.output += `"${this.resolveTokenContent(token.content).trim()}"`; 613 | this.appendComma(); 614 | }); 615 | break; 616 | } 617 | default: { 618 | this.commitOutputTransaction(() => { 619 | this.addTokenToCurrentLine(); 620 | this.output += this.resolveTokenContent(token.content); 621 | this.appendComma(); 622 | }); 623 | } 624 | } 625 | }); 626 | return this.output; 627 | } 628 | 629 | /** 630 | * get the JSON string 631 | * @returns the formatted JSON string 632 | */ 633 | public getString() { 634 | return this.output; 635 | } 636 | } 637 | -------------------------------------------------------------------------------- /src/json5-editor/utils/lineNumber.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import type { RootEnv } from '..'; 4 | import { arrayCollapse, objectCollapse } from '../constant'; 5 | 6 | export const getCollapsedContent = (collapsedList: string[], collapsedContent: string) => { 7 | let newContent = collapsedContent.replace(/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g, (match) => { 8 | const count = match.length - 3; 9 | return collapsedList[count] || match; 10 | }); 11 | 12 | if (/(\{┉\}\u200c*)|(\[┉\]\u200c*)/g.test(newContent)) { 13 | newContent = getCollapsedContent(collapsedList, newContent); 14 | } 15 | 16 | return newContent; 17 | }; 18 | 19 | const getErrorContent = (input: string) => { 20 | let content: string = input || ''; 21 | if ((content.startsWith('"') && content.endsWith('"')) || (content.startsWith("'") && content.endsWith("'"))) { 22 | content = content.slice(1, content.length - 1); 23 | } 24 | if (content.length > 5) { 25 | content = `${content.slice(0, 5)}...`; 26 | } 27 | return content; 28 | }; 29 | 30 | const resizeElements = (elements: HTMLDivElement[]) => { 31 | if (elements.length == 0) { 32 | return; 33 | } 34 | 35 | const infos = elements 36 | .map((element) => { 37 | const codeElement = element.querySelector('pre'); 38 | const lineNumbersWrapper = element.querySelector('.line-numbers-rows')!; 39 | if (!codeElement || !lineNumbersWrapper) { 40 | return undefined; 41 | } 42 | 43 | let lineNumberSizer: HTMLSpanElement = element.querySelector('.line-numbers-sizer')!; 44 | const codeLines = (codeElement.textContent || '').split('\n'); 45 | 46 | if (!lineNumberSizer) { 47 | lineNumberSizer = document.createElement('span'); 48 | lineNumberSizer.className = 'line-numbers-sizer'; 49 | 50 | codeElement.appendChild(lineNumberSizer); 51 | } 52 | 53 | lineNumberSizer.innerHTML = '0'; 54 | lineNumberSizer.style.display = 'block'; 55 | 56 | var oneLinerHeight = lineNumberSizer.getBoundingClientRect().height; 57 | lineNumberSizer.innerHTML = ''; 58 | 59 | return { 60 | element: element, 61 | lines: codeLines, 62 | lineHeights: [] as (number | undefined)[], 63 | oneLinerHeight: oneLinerHeight, 64 | sizer: lineNumberSizer, 65 | }; 66 | }) 67 | .filter(Boolean); 68 | 69 | infos.forEach((info) => { 70 | const lineNumberSizer = info?.sizer!; 71 | const lines = info?.lines || []; 72 | const lineHeights = info?.lineHeights || []; 73 | const oneLinerHeight = info?.oneLinerHeight; 74 | 75 | lineHeights[lines.length - 1] = undefined; 76 | lines.forEach(function (line, index) { 77 | if (line && line.length > 1) { 78 | var e = lineNumberSizer.appendChild(document.createElement('span')); 79 | e.style.display = 'block'; 80 | e.textContent = line; 81 | } else { 82 | lineHeights[index] = oneLinerHeight; 83 | } 84 | }); 85 | }); 86 | 87 | infos.forEach((info) => { 88 | const lineNumberSizer = info?.sizer!; 89 | const lineHeights = info?.lineHeights || []; 90 | 91 | var childIndex = 0; 92 | for (var i = 0; i < lineHeights.length; i++) { 93 | if (lineHeights[i] === undefined) { 94 | lineHeights[i] = lineNumberSizer.children[childIndex++].getBoundingClientRect().height; 95 | } 96 | } 97 | }); 98 | 99 | infos.forEach((info) => { 100 | var lineNumberSizer = info?.sizer!; 101 | var wrapper: HTMLDivElement & { children: HTMLDivElement[] } = info?.element.querySelector('.line-numbers-rows')!; 102 | 103 | lineNumberSizer.style.display = 'none'; 104 | lineNumberSizer.innerHTML = ''; 105 | 106 | (info?.lineHeights || []).forEach((height, lineNumber) => { 107 | if (wrapper.children[lineNumber]) { 108 | // wrapper.children[lineNumber].innerHTML = ''; 109 | wrapper.children[lineNumber].style.height = height + 'px'; 110 | } 111 | }); 112 | }); 113 | }; 114 | 115 | export function addLineNumber(env: RootEnv, onCollapse: (newCode: string, collapsedCode: string, uuid: number) => void, onExpand: (uuid: number) => void) { 116 | requestAnimationFrame(() => { 117 | if (!env.element) { 118 | return; 119 | } 120 | const gutter = env.element.querySelector('.line-numbers-rows')!; 121 | let lines = env.tokenLines as Prism.Token[][]; 122 | if (lines.length === 0) { 123 | lines = [[]]; 124 | } 125 | const errorLineNo = Number('lineNo' in (env.error || {}) ? env.error.lineNo : -1); 126 | 127 | ReactDOM.unmountComponentAtNode(gutter); 128 | 129 | const Gutter: React.FC = () => { 130 | let collapsedLineCount = 0; 131 | 132 | return ( 133 | <> 134 | {lines.map((currentLine, i) => { 135 | let start = ''; 136 | let startColumnNo = 0; 137 | let endColumnNo = 0; 138 | 139 | const getCode = () => { 140 | if (!start) { 141 | return {}; 142 | } 143 | const end = start === '{' ? '}' : ']'; 144 | const rest = lines.slice(i); 145 | let endLineIndex = i; 146 | const startLineIndex = i; 147 | let deepth = 0; 148 | const [firstLine] = rest; 149 | startColumnNo = firstLine 150 | .slice( 151 | 0, 152 | firstLine.findIndex((ele) => ele.content === start), 153 | ) 154 | .reduce((acc, ele) => acc + ele.length, 0); 155 | rest.some((line, e) => { 156 | if (line.some((ele) => ele.content === start)) { 157 | deepth += 1; 158 | } 159 | if (line.some((ele) => ele.content === end)) { 160 | deepth -= 1; 161 | } 162 | if (deepth === 0) { 163 | endColumnNo = line 164 | .slice( 165 | 0, 166 | line.findIndex((ele) => ele.content === end), 167 | ) 168 | .reduce((acc, ele) => acc + ele.length, 0); 169 | endLineIndex = i + e; 170 | return true; 171 | } 172 | }); 173 | const codeLines = env.code.split('\n'); 174 | const collapsedCode = codeLines 175 | .slice(startLineIndex, endLineIndex + 1) 176 | .map((ln, index, arr) => { 177 | if (index === 0) { 178 | return ln.slice(startColumnNo); 179 | } 180 | if (index === arr.length - 1) { 181 | return ln.slice(endColumnNo); 182 | } 183 | return ln; 184 | }) 185 | .join('\n'); 186 | const prePart = codeLines.slice(0, Math.max(startLineIndex, 0)); 187 | const endPart = codeLines.slice(endLineIndex + 1); 188 | const cacheLength = env.collapsedList.length; 189 | const uuid = Array(cacheLength + 1).join('\u200c'); 190 | const newCode = prePart 191 | .concat( 192 | `${codeLines[startLineIndex].slice(0, startColumnNo)}${start === '{' ? `${objectCollapse}${uuid}` : `${arrayCollapse}${uuid}`}${codeLines[endLineIndex].slice(endColumnNo + 1)}`, 193 | ) 194 | .concat(endPart) 195 | .join('\n'); 196 | 197 | return { 198 | newCode, 199 | collapsedCode, 200 | }; 201 | }; 202 | 203 | // error line 204 | if (i + collapsedLineCount === errorLineNo - 1) { 205 | const content = getErrorContent(env.error.token.content || ''); 206 | return ( 207 | 208 | {i + collapsedLineCount + 1} 209 | 210 | 非法字符:"{content}" 211 | 212 | 213 | ); 214 | } 215 | // collapsable line 216 | if ( 217 | currentLine.some((tok) => { 218 | if (typeof tok === 'string') { 219 | return false; 220 | } 221 | if (tok.content === '{') { 222 | start = '{'; 223 | return true; 224 | } 225 | if (tok.content === '[') { 226 | start = '['; 227 | return true; 228 | } 229 | return false; 230 | }) 231 | ) { 232 | return ( 233 | { 235 | const { newCode, collapsedCode } = getCode(); 236 | if (newCode && collapsedCode) { 237 | onCollapse(newCode, collapsedCode, env.collapsedList.length); 238 | } 239 | }} 240 | key={i} 241 | > 242 | {i + collapsedLineCount + 1} 243 | 244 | 245 | ); 246 | } 247 | // expandable line 248 | if (currentLine.some((tok) => typeof tok !== 'string' && tok.type === 'collapse')) { 249 | const collapsedToken = currentLine.find((tok) => typeof tok !== 'string' && tok.type === 'collapse'); 250 | const uuid = (collapsedToken?.content.length || 3) - 3; 251 | const collapsedContent = env.collapsedList[uuid] || ''; 252 | const fullCollapsedContent = getCollapsedContent(env.collapsedList, collapsedContent); 253 | const currentLineNo = i + collapsedLineCount + 1; 254 | const collapsedContentLength = fullCollapsedContent.split('\n').length - 1; 255 | collapsedLineCount += collapsedContentLength; 256 | 257 | if (errorLineNo !== -1 && currentLineNo + collapsedContentLength > errorLineNo - 1) { 258 | const content = getErrorContent(env.error.token.content || ''); 259 | // has Error; 260 | return ( 261 | onExpand(uuid)} key={i}> 262 | {currentLineNo} 263 | 264 | 265 | 266 | 非法字符:"{content}" @line {errorLineNo} 267 | 268 | 269 | 270 | ); 271 | } 272 | 273 | return ( 274 | onExpand(uuid)} key={i}> 275 | {currentLineNo} 276 | 277 | 278 | ); 279 | } 280 | // normal line 281 | return ( 282 | 283 | {i + collapsedLineCount + 1} 284 |   285 | 286 | ); 287 | })} 288 | 289 | ); 290 | }; 291 | 292 | ReactDOM.render(, gutter); 293 | 294 | resizeElements([env.element]); 295 | }); 296 | } 297 | -------------------------------------------------------------------------------- /src/json5-editor/utils/match.ts: -------------------------------------------------------------------------------- 1 | // helper function that cast childNode as span; 2 | const getSpan = (list: NodeListOf, index: number) => { 3 | return list[index] as HTMLSpanElement; 4 | }; 5 | 6 | const isStart = (ele: HTMLSpanElement) => { 7 | return ele.className.includes('brace-start'); 8 | }; 9 | 10 | const getBraceType = (brace: string) => { 11 | switch (brace) { 12 | case '{': 13 | case '}': 14 | return 'brace'; 15 | case '[': 16 | case ']': 17 | return 'bracket'; 18 | case '(': 19 | case ')': 20 | return 'parentheses'; 21 | default: 22 | return ''; 23 | } 24 | }; 25 | 26 | export const activePairs = ( 27 | preElement: HTMLPreElement, 28 | currentIndex: number, 29 | ) => { 30 | setTimeout(() => { 31 | let accCount = 0; 32 | const children = preElement.childNodes || []; 33 | const punctuations = preElement.querySelectorAll('.brace'); 34 | let pair: HTMLSpanElement[] = []; 35 | let pairIndex = 0; 36 | 37 | outer: for (let i = 0; i < children.length; i++) { 38 | const ele = getSpan(children, i); 39 | if (ele.innerText) { 40 | accCount += ele.innerText.length; 41 | } else if (((ele as unknown) as { data: string }).data) { 42 | accCount += ((ele as unknown) as { data: string }).data.length; 43 | } 44 | if (accCount > currentIndex) { 45 | try { 46 | for (let j = 0; j < punctuations.length; j++) { 47 | if (ele.isSameNode(punctuations[j])) { 48 | if (isStart(ele)) { 49 | pair[0] = ele; 50 | } else { 51 | pair[1] = ele; 52 | } 53 | pairIndex = j; 54 | break outer; 55 | } 56 | } 57 | } catch (e) { 58 | // do nothing 59 | } 60 | } 61 | } 62 | 63 | let level = 0; 64 | // 选中了 start 65 | if (pair[0]) { 66 | for (let i = pairIndex; i < punctuations.length; i++) { 67 | const currentElement = getSpan(punctuations, i); 68 | if ( 69 | getBraceType(currentElement.innerText) !== 70 | getBraceType(pair[0].innerText) 71 | ) { 72 | continue; 73 | } 74 | if (isStart(currentElement)) { 75 | level += 1; 76 | } else { 77 | level -= 1; 78 | } 79 | 80 | if (level === 0) { 81 | pair[1] = currentElement; 82 | break; 83 | } 84 | } 85 | } 86 | // 选中了 end 87 | else if (pair[1]) { 88 | for (let i = pairIndex; i >= 0; i--) { 89 | const currentElement = getSpan(punctuations, i); 90 | if ( 91 | getBraceType(currentElement.innerText) !== 92 | getBraceType(pair[1].innerText) 93 | ) { 94 | continue; 95 | } 96 | if (isStart(currentElement)) { 97 | level += 1; 98 | } else { 99 | level -= 1; 100 | } 101 | 102 | if (level === 0) { 103 | pair[0] = currentElement; 104 | break; 105 | } 106 | } 107 | } 108 | 109 | pair.forEach(ele => ele.classList.add('active')); 110 | }); 111 | }; 112 | 113 | export const clearPairs = (preElement: HTMLPreElement) => { 114 | setTimeout(() => { 115 | const children = preElement.childNodes || []; 116 | children.forEach((ele: any) => { 117 | if ( 118 | (ele as HTMLDivElement).className && 119 | (ele as HTMLDivElement).classList.contains('active') 120 | ) { 121 | (ele as HTMLDivElement).classList.remove('active'); 122 | } 123 | }); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /src/json5-editor/utils/prism.ts: -------------------------------------------------------------------------------- 1 | import { endList, startList } from '../constant'; 2 | import { isToken } from './autoComplete'; 3 | 4 | interface WrappedToken extends Prism.Token { 5 | tag: string; 6 | classes: string[]; 7 | attributes: {}; 8 | language: string; 9 | content: WrappedTokenStream; 10 | } 11 | 12 | type WrappedTokenStream = WrappedToken | WrappedToken[] | string | string[]; 13 | 14 | export const lex: Prism.Grammar = { 15 | collapse: [ 16 | { pattern: /\{┉\}\u200c*/, alias: 'object' }, 17 | { pattern: /\[┉\]\u200c*/, alias: 'array' }, 18 | ], 19 | property: [ 20 | { pattern: /("|')(?:\\(?:\r\n?|\n|.)|(?!\1)[^\\\r\n])*\1\*?(?=\s*:)/g, greedy: true }, 21 | { pattern: /(?!\s)[_$a-zA-Z\xA0-\uFFFF\*](?:(?!\s)[$\w\xA0-\uFFFF\*\?])*(?=\s*:)/, alias: 'unquoted' }, 22 | ], 23 | string: { 24 | pattern: /("|')(?:\\(?:\r\n?|\n|.)|(?!\1)[^\\\r\n])*\1/g, 25 | greedy: true, 26 | }, 27 | comment: { 28 | pattern: /\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/g, 29 | greedy: true, 30 | }, 31 | number: /[+-]?\b(?:NaN|Infinity|0x[a-fA-F\d]+)\b|[+-]?(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[eE][+-]?\d+\b)?/, 32 | punctuation: /[{}[\]\|\(\),]/, 33 | operator: /:/, 34 | boolean: /\b(?:true|false)\b/, 35 | null: { 36 | alias: 'keyword', 37 | pattern: /\bnull\b/, 38 | }, 39 | // match leading spaces for every lines besides the first line 40 | leading: [ 41 | { 42 | pattern: /(\r?\n)+[ ]*/g, 43 | lookbehind: true, 44 | inside: { 45 | indent: { 46 | pattern: /[ ]{2}/, 47 | }, 48 | }, 49 | }, 50 | { 51 | pattern: /^[ ]*/g, 52 | greedy: true, 53 | inside: { 54 | indent: { 55 | pattern: /[ ]{2}/, 56 | }, 57 | }, 58 | }, 59 | ], 60 | linebreak: /\r?\n/, 61 | unknown: /(?!\s).+(?=\s*)/, 62 | }; 63 | 64 | /** 65 | * Same as Prism after-tokenize hook, but not mounted globally 66 | * @param this token caches 67 | * @param env Prism.Environment 68 | */ 69 | export const afterTokenizeHook = (env: Prism.Environment) => { 70 | let lastProperty: string | number | symbol = 'root'; 71 | // 当遇到 array 时,插入一个 placeholder symbol,用于在 arrayPrefix 数组中找到真实的 index 后替换 72 | let prefix: Array = []; 73 | let arrayPrefix: number[] = []; 74 | let symbol = Symbol('placeholder'); 75 | const getInnerContent = (str: string) => { 76 | if (str.startsWith('"') || str.startsWith("'")) { 77 | return str.slice(1, str.length - 1); 78 | } 79 | return str; 80 | }; 81 | 82 | for (let i = 0; i < (env.tokens?.length || 0); i++) { 83 | if (env.tokens[i].content === '{') { 84 | prefix.push(lastProperty); 85 | lastProperty = ''; 86 | // prefix.push(lastProperty); 87 | } 88 | if (env.tokens[i].content === '[') { 89 | prefix.push(lastProperty); 90 | arrayPrefix.push(0); 91 | prefix.push(symbol); 92 | lastProperty = ''; 93 | } 94 | if (env.tokens[i].content === '}') { 95 | prefix.pop(); 96 | lastProperty = prefix[prefix.length - 1]; 97 | if (arrayPrefix.length && typeof lastProperty === 'symbol') { 98 | arrayPrefix[arrayPrefix.length - 1]++; 99 | } 100 | lastProperty = ''; 101 | } 102 | if (env.tokens[i].content === ']') { 103 | prefix.pop(); 104 | prefix.pop(); 105 | arrayPrefix.pop(); 106 | lastProperty = prefix[prefix.length - 1]; 107 | if (arrayPrefix.length && typeof lastProperty === 'symbol') { 108 | arrayPrefix[arrayPrefix.length - 1]++; 109 | } 110 | lastProperty = ''; 111 | } 112 | if (env.tokens[i].type === 'property') { 113 | lastProperty = getInnerContent(env.tokens[i].content); 114 | let arrayIndex = 0; 115 | env.tokens[i].alias = `${env.tokens[i].alias || ''} ${[...prefix, lastProperty] 116 | .filter((ele) => ele !== '') 117 | .map((ele) => (typeof ele === 'symbol' ? arrayPrefix[arrayIndex++] : ele)) 118 | .join('.')}`.trim(); 119 | } 120 | } 121 | }; 122 | 123 | const getObjectPath = (env: WrappedToken) => { 124 | const extraClassList = (env.classes[2] || '').split(' '); 125 | return extraClassList[extraClassList.length - 1]; 126 | }; 127 | 128 | /** 129 | * pre-wrap tokens, add classNames and attributes to token 130 | * @param token 131 | * @param language 132 | * @returns 133 | */ 134 | function preWrap(token: Prism.Token | Prism.TokenStream | string, language: string): WrappedTokenStream { 135 | if (typeof token == 'string') { 136 | return token; 137 | } 138 | if (Array.isArray(token)) { 139 | return token.map((tok) => { 140 | return preWrap(tok, language); 141 | }) as WrappedTokenStream; 142 | } 143 | 144 | const env: WrappedToken = { 145 | ...token, 146 | tag: 'span', 147 | classes: ['token', token.type], 148 | attributes: {}, 149 | language: language, 150 | content: preWrap(token.content, language), 151 | }; 152 | const aliases = token.alias; 153 | if (aliases) { 154 | if (Array.isArray(aliases)) { 155 | Array.prototype.push.apply(env.classes, aliases); 156 | } else { 157 | env.classes.push(aliases); 158 | } 159 | } 160 | return env; 161 | } 162 | 163 | function stringify(token: WrappedTokenStream, language: string): string { 164 | if (typeof token == 'string') { 165 | return token; 166 | } 167 | if (Array.isArray(token)) { 168 | let ret = ''; 169 | token.forEach((tok: WrappedTokenStream) => { 170 | ret += stringify(tok, language); 171 | }); 172 | return ret; 173 | } 174 | 175 | let env: Prism.hooks.RequiredEnvironment<'classes' | 'content', Prism.Environment> = { 176 | ...token, 177 | content: stringify(token.content, language), 178 | }; 179 | 180 | if (startList.includes(env.content)) { 181 | env.classes.push('brace', 'brace-start'); 182 | } 183 | if (endList.includes(env.content)) { 184 | env.classes.push('brace', 'brace-end'); 185 | } 186 | 187 | if (env.hasError) { 188 | env.classes.push('error'); 189 | } 190 | 191 | let attributes = ''; 192 | for (const name in env.attributes) { 193 | attributes += ' ' + name + '="' + (env.attributes[name] || '').replace(/"/g, '"') + '"'; 194 | } 195 | 196 | return '<' + env.tag + ' class="' + env.classes.join(' ') + '"' + attributes + '>' + env.content + ''; 197 | } 198 | 199 | /** 200 | * encode tokens and keep attributes 201 | * @param tokens Prism.TokenStream 202 | * @returns string 203 | */ 204 | const encode = (tokens: string | WrappedToken | WrappedToken[]): WrappedToken | WrappedToken[] | string | string[] => { 205 | if (isToken(tokens)) { 206 | return tokens; 207 | } else if (Array.isArray(tokens)) { 208 | return tokens.map(encode) as WrappedToken[]; 209 | } else { 210 | return tokens 211 | .replace(/&/g, '&') 212 | .replace(/ { 218 | const encoded = encode(token as WrappedToken); 219 | const cache: string[] = []; 220 | const tokens = preWrap(Array.isArray(encoded) ? encoded : [encoded], language) as WrappedToken[]; 221 | 222 | for (let i = tokens.length - 1; i >= 0; i--) { 223 | const current = tokens[i]; 224 | if (typeof current !== 'string' && current.type === 'property') { 225 | const objectPath = getObjectPath(current); 226 | if (cache.includes(objectPath)) { 227 | current.classes.push('exist-property'); 228 | } else { 229 | cache.push(objectPath); 230 | } 231 | } 232 | } 233 | 234 | return stringify(tokens, language); 235 | }; 236 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "es", 4 | "declaration": true, 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": ["esnext", "dom"], 8 | "sourceMap": true, 9 | "baseUrl": ".", 10 | "jsx": "react", 11 | "allowSyntheticDefaultImports": true, 12 | "moduleResolution": "node", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "suppressImplicitAnyIndexErrors": true, 16 | "noUnusedLocals": true, 17 | "skipLibCheck": true, 18 | "esModuleInterop": true, 19 | "experimentalDecorators": true, 20 | "strict": true 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": ["node_modules", "dist"] 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./dist", 5 | "target": "esnext", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "esModuleInterop": true, 11 | "emitDeclarationOnly": true, 12 | "sourceMap": true, 13 | "skipLibCheck": true, 14 | "baseUrl": "./", 15 | "strict": true, 16 | "paths": { 17 | "@/*": ["src/*"], 18 | "@@/*": ["src/.umi/*"] 19 | }, 20 | "allowSyntheticDefaultImports": true 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "lib", 25 | "es", 26 | "dist", 27 | "typings", 28 | "**/__test__", 29 | "test", 30 | "docs", 31 | "tests" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module 'prismjs/components/prism-core' { 4 | const Prism = await import('prismjs'); 5 | export = Prism; 6 | } 7 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | let libraryName = 'index'; 4 | 5 | module.exports = { 6 | entry: `${__dirname}/src/index.ts`, 7 | devtool: 'source-map', 8 | mode: 'production', 9 | 10 | output: { 11 | path: `${__dirname}/dist`, 12 | filename: `${libraryName}.js`, 13 | library: libraryName, 14 | libraryTarget: 'umd', 15 | globalObject: "(typeof self !== 'undefined' ? self : this)", 16 | umdNamedDefine: true, 17 | }, 18 | externals: { 19 | react: { 20 | root: 'React', 21 | commonjs2: 'react', 22 | commonjs: 'react', 23 | amd: 'react', 24 | umd: 'react', 25 | }, 26 | 'react-dom': { 27 | root: 'ReactDOM', 28 | commonjs2: 'react-dom', 29 | commonjs: 'react-dom', 30 | amd: 'react-dom', 31 | umd: 'react-dom', 32 | }, 33 | }, 34 | resolve: { 35 | alias: { 36 | '@': path.resolve(__dirname, 'src'), 37 | }, 38 | extensions: ['.ts', '.tsx', '.js', '.less'], 39 | }, 40 | module: { 41 | rules: [ 42 | { 43 | test: /(\.tsx|\.ts|\.jsx|\.js)$/, 44 | loader: 'babel-loader', 45 | exclude: /node_modules/, 46 | }, 47 | { 48 | test: /\.less|\.css$/, 49 | include: [path.resolve(__dirname, 'src')], 50 | use: [ 51 | { 52 | loader: 'style-loader', 53 | }, 54 | { 55 | loader: 'css-loader', 56 | }, 57 | { 58 | loader: 'less-loader', 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | }; 65 | --------------------------------------------------------------------------------