├── .browserslistrc ├── .commitlintrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build-and-test.yml │ ├── codeql-analysis.yml │ └── release-please.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .release-please-manifest.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── examples │ ├── JsonViewerCustomizeDate.tsx │ ├── JsonViewerPreview.tsx │ ├── JsonViewerToggleBoolean.tsx │ ├── JsonViewerWithImage.tsx │ ├── JsonViewerWithURL.tsx │ └── JsonViewerWithWidget.tsx ├── hooks │ └── useTheme.ts ├── lib │ └── shared.ts ├── next.config.mjs ├── package.json ├── pages │ ├── _app.mdx │ ├── _meta.json │ ├── apis.mdx │ ├── full │ │ └── index.tsx │ ├── how-to │ │ ├── _meta.json │ │ ├── built-in-types.mdx │ │ ├── data-types.mdx │ │ └── styling.mdx │ ├── index.mdx │ └── migration │ │ ├── _meta.json │ │ ├── migration-v3.mdx │ │ └── migration-v4.mdx ├── theme.config.js └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── public ├── avatar-preview.png └── ocean-theme.png ├── release-please-config.json ├── rollup.config.ts ├── src ├── browser.tsx ├── components │ ├── DataKeyPair.tsx │ ├── DataTypeLabel.tsx │ ├── DataTypes │ │ ├── Boolean.tsx │ │ ├── Date.tsx │ │ ├── Function.tsx │ │ ├── Null.tsx │ │ ├── Number.tsx │ │ ├── Object.tsx │ │ ├── String.tsx │ │ ├── Undefined.tsx │ │ ├── defineEasyType.tsx │ │ └── index.ts │ ├── Icons.tsx │ └── mui │ │ └── DataBox.tsx ├── hooks │ ├── useColor.ts │ ├── useCopyToClipboard.ts │ ├── useInspect.ts │ ├── useIsCycleReference.ts │ └── useThemeDetector.ts ├── index.tsx ├── stores │ ├── JsonViewerStore.ts │ └── typeRegistry.tsx ├── theme │ └── base16.ts ├── type.ts └── utils │ └── index.ts ├── tests ├── index.test.tsx ├── setup.ts ├── tsconfig.json └── util.test.tsx ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | # sync with https://github.com/mui/material-ui/blob/v6.1.0/.browserslistrc#L17-L73 2 | and_chr 122 3 | and_chr 121 4 | and_ff 123 5 | and_ff 122 6 | and_qq 14.9 7 | and_uc 15.5 8 | # android 122 9 | # android 121 10 | chrome 122 11 | chrome 121 12 | chrome 120 13 | chrome 119 14 | chrome 109 15 | edge 122 16 | edge 121 17 | firefox 123 18 | firefox 122 19 | firefox 115 20 | ios_saf 17.4 21 | ios_saf 17.3 22 | ios_saf 17.2 23 | ios_saf 17.1 24 | ios_saf 17.0 25 | ios_saf 16.6-16.7 26 | ios_saf 16.5 27 | ios_saf 16.4 28 | ios_saf 16.3 29 | ios_saf 16.2 30 | ios_saf 16.1 31 | ios_saf 16.0 32 | ios_saf 15.6-15.8 33 | ios_saf 15.5 34 | ios_saf 15.4 35 | kaios 3.0-3.1 36 | kaios 2.5 37 | op_mini all 38 | op_mob 80 39 | opera 108 40 | opera 107 41 | opera 106 42 | safari 17.4 43 | safari 17.3 44 | safari 17.2 45 | safari 17.1 46 | safari 17.0 47 | safari 16.6 48 | safari 16.5 49 | safari 16.4 50 | safari 16.3 51 | safari 16.2 52 | safari 16.1 53 | safari 16.0 54 | safari 15.6 55 | safari 15.5 56 | safari 15.4 57 | samsung 23 58 | samsung 22 59 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "scope-case": [2, "always", "lower-case"], 5 | "type-enum": [ 6 | 2, 7 | "always", 8 | [ 9 | "chore", 10 | "build", 11 | "ci", 12 | "docs", 13 | "feat", 14 | "fix", 15 | "perf", 16 | "refactor", 17 | "revert", 18 | "style", 19 | "test", 20 | "types", 21 | "workflow", 22 | "wip" 23 | ] 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | [{*.js, *.jsx, *.ts, *.tsx, *.json, *.yml, *.yaml}] 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | settings: { 4 | react: { 5 | version: 'detect' 6 | } 7 | }, 8 | env: { 9 | browser: true, es6: true 10 | }, 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:react/recommended', 14 | 'plugin:react/jsx-runtime', 15 | 'standard' 16 | ], 17 | globals: { 18 | Atomics: 'readonly', SharedArrayBuffer: 'readonly' 19 | }, 20 | parser: '@typescript-eslint/parser', 21 | parserOptions: { 22 | ecmaFeatures: { 23 | globalReturn: false, impliedStrict: true, jsx: true 24 | }, 25 | ecmaVersion: 'latest', 26 | sourceType: 'module' 27 | }, 28 | plugins: [ 29 | 'react', 30 | 'react-hooks', 31 | '@typescript-eslint', 32 | 'simple-import-sort', 33 | 'import', 34 | 'unused-imports' 35 | ], 36 | rules: { 37 | eqeqeq: 'error', 38 | 'no-eval': 'error', 39 | 'no-var': 'error', 40 | 'prefer-const': 'error', 41 | 'sort-imports': 'off', 42 | 'import/order': 'off', 43 | 'simple-import-sort/imports': 'error', 44 | 'simple-import-sort/exports': 'error', 45 | 'import/first': 'error', 46 | 'import/newline-after-import': 'error', 47 | 'import/no-duplicates': 'error', 48 | 'no-unused-vars': 'off', 49 | 'unused-imports/no-unused-imports': 'error', 50 | 'unused-imports/no-unused-vars': [ 51 | 'warn', { 52 | vars: 'all', 53 | varsIgnorePattern: '^_', 54 | args: 'after-used', 55 | argsIgnorePattern: '^_' 56 | }], 57 | 'no-use-before-define': 'off', 58 | '@typescript-eslint/no-use-before-define': ['error'], 59 | 'no-redeclare': 'off', 60 | '@typescript-eslint/no-redeclare': ['error'], 61 | 'no-unused-expressions': 'warn', 62 | 'react/jsx-filename-extension': [ 63 | 1, { 64 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 65 | }], 66 | 'import/prefer-default-export': 'off', 67 | 'jsx-quotes': ['error', 'prefer-single'], 68 | camelcase: 'off', 69 | 'react/prop-types': 'off', 70 | 'react/display-name': 'off', 71 | indent: ['error', 2, { 72 | SwitchCase: 1, 73 | offsetTernaryExpressions: true, 74 | // from: https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/style.js 75 | ignoredNodes: [ 76 | 'JSXElement', 77 | 'JSXElement :not(JSXExpressionContainer, JSXExpressionContainer *)', 78 | 'JSXAttribute', 79 | 'JSXIdentifier', 80 | 'JSXNamespacedName', 81 | 'JSXMemberExpression', 82 | 'JSXSpreadAttribute', 83 | 'JSXOpeningElement', 84 | 'JSXClosingElement', 85 | 'JSXFragment', 86 | 'JSXOpeningFragment', 87 | 'JSXClosingFragment', 88 | 'JSXText', 89 | 'JSXEmptyExpression', 90 | 'JSXSpreadChild' 91 | ] 92 | }], 93 | 'react/jsx-indent': ['error', 2, { checkAttributes: false, indentLogicalExpressions: true }], 94 | 'react/jsx-indent-props': ['error', 2], 95 | 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 96 | 'react/jsx-closing-bracket-location': ['error', 'tag-aligned'], 97 | 'react/jsx-max-props-per-line': ['error', { maximum: 1, when: 'multiline' }], 98 | 'react/jsx-tag-spacing': ['error', { 99 | closingSlash: 'never', 100 | beforeSelfClosing: 'always', 101 | afterOpening: 'never', 102 | beforeClosing: 'never' 103 | }], 104 | 'react/jsx-wrap-multilines': ['error', { 105 | declaration: 'parens-new-line', 106 | assignment: 'parens-new-line', 107 | return: 'parens-new-line', 108 | arrow: 'parens-new-line', 109 | condition: 'parens-new-line', 110 | logical: 'parens-new-line', 111 | prop: 'parens-new-line' 112 | }], 113 | 'react/jsx-curly-spacing': ['error', 'never', { allowMultiline: true }], 114 | 'react/jsx-curly-newline': ['error', { 115 | multiline: 'consistent', 116 | singleline: 'consistent' 117 | }], 118 | '@typescript-eslint/no-var-requires': 'off', 119 | '@typescript-eslint/camelcase': 'off', 120 | '@typescript-eslint/ban-ts-ignore': 'off', 121 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', disallowTypeAnnotations: false }], 122 | '@typescript-eslint/explicit-function-return-type': 'off', 123 | 'react-hooks/rules-of-hooks': 'error', 124 | 'react-hooks/exhaustive-deps': 'warn', 125 | 'no-restricted-imports': 'off', 126 | '@typescript-eslint/no-restricted-imports': [ 127 | 'error', 128 | { 129 | patterns: [ 130 | { 131 | group: ['**/dist'], 132 | message: 'Don\'t import from dist', 133 | allowTypeImports: false 134 | } 135 | ] 136 | } 137 | ] 138 | }, 139 | overrides: [ 140 | { 141 | files: ['*.d.ts'], 142 | rules: { 143 | 'no-undef': 'off' 144 | } 145 | }, 146 | { 147 | files: ['*.test.ts', '*.test.tsx'], env: { jest: true } 148 | } 149 | ] 150 | } 151 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TexteaInc] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | versioning-strategy: increase # Update package.json too 8 | groups: 9 | patterns: 10 | update-types: 11 | - "minor" 12 | - "patch" -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**.tsx?' 9 | - '**.jsx?' 10 | pull_request: 11 | branches: 12 | - main 13 | - v3.x 14 | 15 | jobs: 16 | test: 17 | name: Running vitest 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Install pnpm 25 | uses: pnpm/action-setup@v4 26 | 27 | - name: Use Node.js LTS 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: '.nvmrc' 31 | cache: 'pnpm' 32 | 33 | - name: Install Dependencies 34 | run: pnpm install 35 | 36 | - name: Lint 37 | run: pnpm run lint:ci 38 | 39 | - name: Build 40 | run: pnpm run build 41 | 42 | - name: Test with Coverage 43 | run: pnpm run coverage 44 | 45 | - uses: codecov/codecov-action@v4 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - '**.tsx?' 9 | - '**.jsx?' 10 | pull_request: 11 | branches: 12 | - main 13 | - v3.x 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | permissions: 20 | actions: read 21 | contents: read 22 | security-events: write 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | language: [ 'javascript' ] 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v3 32 | with: 33 | languages: ${{ matrix.language }} 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v3 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v3 38 | with: 39 | category: "/language:${{matrix.language}}" 40 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: 'Release Please' 9 | runs-on: ubuntu-latest 10 | outputs: 11 | release_created: ${{ steps.release.outputs.release_created }} 12 | steps: 13 | - uses: googleapis/release-please-action@v4 14 | id: release 15 | with: 16 | token: ${{secrets.GITHUB_TOKEN}} 17 | publish: 18 | name: 'Publish @textea/json-viewer' 19 | needs: release 20 | if: needs.release.outputs.release_created 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | id-token: write 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Use Node.js LTS 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: 20 33 | cache: 'pnpm' 34 | 35 | - name: Install pnpm 36 | uses: pnpm/action-setup@v4 37 | 38 | - name: Use Node.js LTS 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version-file: '.nvmrc' 42 | cache: 'pnpm' 43 | 44 | - name: Install Dependencies 45 | run: pnpm install 46 | 47 | - name: Prepack 48 | run: pnpm run prepack 49 | 50 | - name: Build 51 | run: npm run build 52 | 53 | - name: Clean package.json 54 | run: | 55 | npm pkg delete scripts 56 | npm pkg delete lint-staged 57 | 58 | - name: Publish to npm 59 | run: npm publish --provenance --access public 60 | env: 61 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | pnpm-debug.log* 10 | lerna-debug.log* 11 | 12 | # yarn 13 | **/.yarn/* 14 | **/!.yarn/patches 15 | **/!.yarn/plugins 16 | **/!.yarn/releases 17 | **/!.yarn/sdks 18 | **/!.yarn/versions 19 | 20 | node_modules 21 | dist 22 | dist-ssr 23 | coverage 24 | *.local 25 | .idea 26 | .vscode 27 | .next 28 | .vercel 29 | 30 | # Editor directories and files 31 | .vscode/* 32 | !.vscode/extensions.json 33 | .idea 34 | .DS_Store 35 | *.suo 36 | *.ntvs* 37 | *.njsproj 38 | *.sln 39 | *.sw? 40 | 41 | # cache 42 | tsconfig.tsbuildinfo 43 | .eslintcache 44 | node_modules 45 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .husky/ 3 | package.json 4 | pnpm-lock.yaml 5 | 6 | *generated* 7 | storybook-static 8 | 9 | # document 10 | *.mdx 11 | 12 | # VSCode personal settings 13 | .vscode/launch.json 14 | .vscode/tasks.json 15 | 16 | # JetBrain personal settings 17 | .idea 18 | 19 | # testing 20 | /reports 21 | /junit.xml 22 | 23 | # Build out 24 | dist/* 25 | /build 26 | /storybook-static 27 | 28 | # Environment files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # Block-chain contract files 35 | /contracts 36 | 37 | # Temp profiles 38 | .firefox 39 | .chrome 40 | 41 | # Following content is copied from https://github.com/github/gitignore/blob/master/Node.gitignore 42 | # Logs 43 | logs 44 | *.log 45 | npm-debug.log* 46 | yarn-debug.log* 47 | yarn-error.log* 48 | pnpm-debug.log* 49 | lerna-debug.log* 50 | 51 | # Diagnostic reports (https://nodejs.org/api/report.html) 52 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 53 | 54 | # Runtime data 55 | pids 56 | *.pid 57 | *.seed 58 | *.pid.lock 59 | 60 | # Directory for instrumented libs generated by jscoverage/JSCover 61 | lib-cov 62 | 63 | # Coverage directory used by tools like istanbul 64 | coverage 65 | *.lcov 66 | 67 | # nyc test coverage 68 | .nyc_output 69 | 70 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 71 | .grunt 72 | 73 | # Bower dependency directory (https://bower.io/) 74 | bower_components 75 | 76 | # node-waf configuration 77 | .lock-wscript 78 | 79 | # Compiled binary addons (https://nodejs.org/api/addons.html) 80 | build/Release 81 | 82 | # Dependency directories 83 | node_modules/ 84 | jspm_packages/ 85 | 86 | # Snowpack dependency directory (https://snowpack.dev/) 87 | web_modules/ 88 | 89 | # TypeScript cache 90 | *.tsbuildinfo 91 | 92 | # Optional npm cache directory 93 | .npm 94 | 95 | # Optional eslint cache 96 | .eslintcache 97 | 98 | # Microbundle cache 99 | .rpt2_cache/ 100 | .rts2_cache_cjs/ 101 | .rts2_cache_es/ 102 | .rts2_cache_umd/ 103 | 104 | # Optional REPL history 105 | .node_repl_history 106 | 107 | # Output of 'npm pack' 108 | *.tgz 109 | 110 | # Yarn Integrity file 111 | .yarn-integrity 112 | 113 | # dotenv environment variables file 114 | .env 115 | .env.test 116 | 117 | # parcel-bundler cache (https://parceljs.org/) 118 | .cache 119 | .parcel-cache 120 | 121 | # Next.js build output 122 | .next 123 | out 124 | 125 | # Nuxt.js build / generate output 126 | .nuxt 127 | dist 128 | 129 | # Gatsby files 130 | .cache/ 131 | # Comment in the public line in if your project uses Gatsby and not Next.js 132 | # https://nextjs.org/blog/next-9-1#public-directory-support 133 | # public 134 | 135 | # vuepress build output 136 | .vuepress/dist 137 | 138 | # Serverless directories 139 | .serverless/ 140 | 141 | # FuseBox cache 142 | .fusebox/ 143 | 144 | # DynamoDB Local files 145 | .dynamodb/ 146 | 147 | # TernJS port file 148 | .tern-port 149 | 150 | # Stores VSCode versions used for testing VSCode extensions 151 | .vscode-test 152 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "bracketSameLine": true 7 | } 8 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "4.0.1" 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | bao at textea dot co. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 Textea, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @textea/json-viewer 2 | 3 | [![npm](https://img.shields.io/npm/v/@textea/json-viewer)](https://www.npmjs.com/package/@textea/json-viewer) 4 | [![npm](https://img.shields.io/npm/dm/@textea/json-viewer.svg)](https://www.npmjs.com/package/@textea/json-viewer) 5 | [![npm](https://img.shields.io/npm/l/@textea/json-viewer)](https://github.com/TexteaInc/json-viewer/blob/main/LICENSE) 6 | [![codecov](https://codecov.io/gh/TexteaInc/json-viewer/branch/main/graph/badge.svg?token=r32mzVhrRl)](https://codecov.io/gh/TexteaInc/json-viewer) 7 | [![Netlify Status](https://api.netlify.com/api/v1/badges/c2aa0ee1-979b-4512-85d2-f27e63897df0/deploy-status)](https://viewer.textea.io) 8 | 9 | `@textea/json-viewer` is a React component that can be used to view and display any kind of data, not just JSON. 10 | 11 | ~~Json Viewer?~~ 12 | **ANY Data Viewer** ✅ 13 | 14 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/edit/textea-json-viewer-v4-b4wgxq-qzsnukyr?file=pages%2Findex.js) 15 | 16 | ## Features 🚀 17 | 18 | - 🦾 100% TypeScript 19 | - 🎨 Customizable: Key, value, editable, copy, select... Anything you can think of! 20 | - 🌈 Theme support: light or dark, or use [Base16](https://github.com/chriskempson/base16) themes. 21 | - ⚛️ SSR Ready 22 | - 📋 Copy to Clipboard 23 | - 🔍 Inspect anything: `Object`, `Array`, primitive types, and even `Map` and `Set`. 24 | - 📊 Metadata preview: Total items, length of string... 25 | - ✏️ Editor: Comes with an editor for basic types, which you can also customize to fit your use case. 26 | 27 | ## Installation 28 | 29 | `@textea/json-viewer` is using [Material-UI](https://mui.com/) as the base component library, so you need to install it and its peer dependencies first. 30 | 31 | ```sh 32 | npm install @textea/json-viewer @mui/material @emotion/react @emotion/styled 33 | ``` 34 | 35 | ### CDN 36 | 37 | ```html 38 | 39 | 40 | 41 |
42 | 43 | 50 | 51 | 52 | ``` 53 | 54 | ## Usage 55 | 56 | Here is a basic example: 57 | 58 | ```jsx 59 | import { JsonViewer } from '@textea/json-viewer' 60 | 61 | const object = { 62 | /* my json object */ 63 | } 64 | const Component = () => 65 | ``` 66 | 67 | ### Customization 68 | 69 | You can define custom data types to handle data that is not supported out of the box. Here is an example of how to display an image: 70 | 71 | ```jsx 72 | import { JsonViewer, defineDataType } from '@textea/json-viewer' 73 | 74 | const object = { 75 | image: 'https://i.imgur.com/1bX5QH6.jpg' 76 | // ... other values 77 | } 78 | 79 | // Let's define a data type for image 80 | const imageDataType = defineDataType({ 81 | is: (value) => typeof value === 'string' && value.startsWith('https://i.imgur.com'), 82 | Component: (props) => {props.value} 83 | }) 84 | 85 | const Component = () => 86 | ``` 87 | 88 | ![Avatar Preview](public/avatar-preview.png) 89 | 90 | [see the full code](docs/pages/full/index.tsx) 91 | 92 | ## Theme 93 | 94 | Please refer to [Styling and Theming](https://viewer.textea.io/how-to/styling) 95 | 96 | ![Ocean Theme Preview](public/ocean-theme.png) 97 | 98 | ## Contributors 99 | 100 | 101 | 102 | ## Acknowledge 103 | 104 | This package is originally based on [mac-s-g/react-json-view](https://github.com/mac-s-g/react-json-view). 105 | 106 | Also thanks open source projects that make this possible. 107 | 108 | ## Sponsoring services 109 | 110 | ![Netlify](https://www.netlify.com/v3/img/components/full-logo-light.svg) 111 | 112 | [Netlify](https://www.netlify.com/) lets us distribute the [site](https://viewer.textea.io). 113 | 114 | ## LICENSE 115 | 116 | This project is licensed under the terms of the [MIT license](LICENSE). 117 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # nextra 39 | public/feed.xml 40 | -------------------------------------------------------------------------------- /docs/examples/JsonViewerCustomizeDate.tsx: -------------------------------------------------------------------------------- 1 | import { defineEasyType, JsonViewer } from '@textea/json-viewer' 2 | import type { FC } from 'react' 3 | 4 | import { useNextraTheme } from '../hooks/useTheme' 5 | 6 | const myDateType = defineEasyType({ 7 | is: (value) => value instanceof Date, 8 | type: 'date', 9 | colorKey: 'base0D', 10 | Renderer: ({ value }) => <>{value.toISOString().split('T')[0]} 11 | }) 12 | 13 | const value = { 14 | date: new Date('2023/04/12 12:34:56') 15 | } 16 | 17 | const Example: FC = () => { 18 | const theme = useNextraTheme() 19 | return ( 20 | 27 | ) 28 | } 29 | 30 | export default Example 31 | -------------------------------------------------------------------------------- /docs/examples/JsonViewerPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { JsonViewerProps } from '@textea/json-viewer' 2 | import { JsonViewer } from '@textea/json-viewer' 3 | import type { FC } from 'react' 4 | 5 | import { useNextraTheme } from '../hooks/useTheme' 6 | 7 | export const JsonViewerPreview: FC = (props) => { 8 | const theme = useNextraTheme() 9 | return ( 10 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /docs/examples/JsonViewerToggleBoolean.tsx: -------------------------------------------------------------------------------- 1 | import type { JsonViewerOnChange } from '@textea/json-viewer' 2 | import { applyValue, booleanType, defineDataType, defineEasyType, JsonViewer } from '@textea/json-viewer' 3 | import type { FC } from 'react' 4 | import { useCallback, useMemo, useState } from 'react' 5 | 6 | import { useNextraTheme } from '../hooks/useTheme' 7 | 8 | const value = { 9 | agree: true, 10 | disagree: false, 11 | description: 'Click the ✔️ ❌ to toggle the boolean value' 12 | } 13 | 14 | export const JsonViewerToggleBoolean1: FC = () => { 15 | const theme = useNextraTheme() 16 | const [src, setSrc] = useState(value) 17 | const onChange = useCallback( 18 | (path, oldValue, newValue) => { 19 | setSrc(src => applyValue(src, path, newValue)) 20 | }, []) 21 | 22 | const toggleBoolType = useMemo(() => { 23 | return defineDataType({ 24 | ...booleanType, 25 | Component: ({ value, path }) => ( 26 | onChange(path, value, !value)}> 27 | {value ? '✔️' : '❌'} 28 | 29 | ) 30 | }) 31 | }, [onChange]) 32 | 33 | return ( 34 | 42 | ) 43 | } 44 | 45 | export const JsonViewerToggleBoolean2: FC = () => { 46 | const theme = useNextraTheme() 47 | const [src, setSrc] = useState(value) 48 | const onChange = useCallback( 49 | (path, oldValue, newValue) => { 50 | setSrc(src => applyValue(src, path, newValue)) 51 | }, []) 52 | 53 | const toggleBoolType = useMemo(() => { 54 | return defineEasyType({ 55 | ...booleanType, 56 | type: 'bool', 57 | colorKey: 'base0E', 58 | Renderer: ({ value, path }) => ( 59 | onChange(path, value, !value)}> 60 | {value ? '✔️' : '❌'} 61 | 62 | ) 63 | }) 64 | }, [onChange]) 65 | 66 | return ( 67 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /docs/examples/JsonViewerWithImage.tsx: -------------------------------------------------------------------------------- 1 | import { defineDataType, JsonViewer } from '@textea/json-viewer' 2 | import type { FC } from 'react' 3 | 4 | import { useNextraTheme } from '../hooks/useTheme' 5 | 6 | const imageType = defineDataType({ 7 | is: (value) => { 8 | if (typeof value !== 'string') return false 9 | try { 10 | const url = new URL(value) 11 | return url.pathname.endsWith('.jpg') 12 | } catch { 13 | return false 14 | } 15 | }, 16 | Component: (props) => { 17 | return ( 18 | {props.value} 25 | ) 26 | } 27 | }) 28 | 29 | const value = { 30 | image: 'https://i.imgur.com/1bX5QH6.jpg' 31 | } 32 | 33 | const Example: FC = () => { 34 | const theme = useNextraTheme() 35 | return ( 36 | 43 | ) 44 | } 45 | 46 | export default Example 47 | -------------------------------------------------------------------------------- /docs/examples/JsonViewerWithURL.tsx: -------------------------------------------------------------------------------- 1 | import { defineDataType, JsonViewer } from '@textea/json-viewer' 2 | import type { FC } from 'react' 3 | 4 | import { useNextraTheme } from '../hooks/useTheme' 5 | 6 | const urlType = defineDataType({ 7 | is: (value) => value instanceof URL, 8 | Component: (props) => { 9 | const url = props.value.toString() 10 | return ( 11 | 21 | {url} 22 | 23 | ) 24 | } 25 | }) 26 | 27 | const value = { 28 | string: 'this is a string', 29 | url: new URL('https://example.com') 30 | } 31 | 32 | const Example: FC = () => { 33 | const theme = useNextraTheme() 34 | return ( 35 | 42 | ) 43 | } 44 | 45 | export default Example 46 | -------------------------------------------------------------------------------- /docs/examples/JsonViewerWithWidget.tsx: -------------------------------------------------------------------------------- 1 | import type { SvgIconProps } from '@mui/material' 2 | import { Button, SvgIcon } from '@mui/material' 3 | import { defineDataType, JsonViewer, stringType } from '@textea/json-viewer' 4 | import type { FC } from 'react' 5 | 6 | import { useNextraTheme } from '../hooks/useTheme' 7 | 8 | const LinkIcon = (props: SvgIconProps) => ( 9 | // 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | 18 | const linkType = defineDataType({ 19 | ...stringType, 20 | is (value) { 21 | return typeof value === 'string' && value.startsWith('http') 22 | }, 23 | PostComponent: (props) => ( 24 | 38 | ) 39 | }) 40 | 41 | const value = { 42 | link: 'http://example.com' 43 | } 44 | 45 | const Example: FC = () => { 46 | const theme = useNextraTheme() 47 | return ( 48 | 55 | ) 56 | } 57 | 58 | export default Example 59 | -------------------------------------------------------------------------------- /docs/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import type { JsonViewerTheme } from '@textea/json-viewer' 2 | import { useTheme } from 'nextra-theme-docs' 3 | 4 | export function useNextraTheme () { 5 | const { theme, systemTheme } = useTheme() 6 | const currentTheme = (theme === 'system' ? systemTheme : theme) as JsonViewerTheme 7 | return currentTheme 8 | } 9 | -------------------------------------------------------------------------------- /docs/lib/shared.ts: -------------------------------------------------------------------------------- 1 | import type { NamedColorspace } from '@textea/json-viewer' 2 | 3 | export const ocean: NamedColorspace = { 4 | scheme: 'Ocean', 5 | author: 'Chris Kempson (http://chriskempson.com)', 6 | base00: '#2b303b', 7 | base01: '#343d46', 8 | base02: '#4f5b66', 9 | base03: '#65737e', 10 | base04: '#a7adba', 11 | base05: '#c0c5ce', 12 | base06: '#dfe1e8', 13 | base07: '#eff1f5', 14 | base08: '#bf616a', 15 | base09: '#d08770', 16 | base0A: '#ebcb8b', 17 | base0B: '#a3be8c', 18 | base0C: '#96b5b4', 19 | base0D: '#8fa1b3', 20 | base0E: '#b48ead', 21 | base0F: '#ab7967' 22 | } 23 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import nextra from 'nextra' 2 | 3 | const withNextra = nextra({ 4 | theme: 'nextra-theme-docs', 5 | themeConfig: './theme.config.js', 6 | staticImage: true, 7 | defaultShowCopyCode: true 8 | }) 9 | 10 | /** @type {import('next').NextConfig} */ 11 | const nextConfig = { 12 | reactStrictMode: true, 13 | optimizeFonts: true, 14 | images: { 15 | domains: ['i.imgur.com', 'www.netlify.com'] 16 | }, 17 | transpilePackages: ['@textea/json-viewer'] 18 | } 19 | 20 | export default withNextra(nextConfig) 21 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textea/json-viewer-docs", 3 | "private": true, 4 | "engines": { 5 | "node": ">=16.0.0" 6 | }, 7 | "scripts": { 8 | "dev": "next", 9 | "build": "next build", 10 | "start": "next start" 11 | }, 12 | "dependencies": { 13 | "@textea/json-viewer": "workspace:^", 14 | "gray-matter": "^4.0.3", 15 | "next": "^13.5.7", 16 | "nextra": "2.13.3", 17 | "nextra-theme-docs": "2.13.3", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.17.9", 23 | "@types/react": "^18.3.11", 24 | "@types/react-dom": "^18.3.1", 25 | "typescript": "^5.7.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import 'nextra-theme-docs/style.css' 2 | 3 | export default function Nextra({ Component, pageProps }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Introduction", 3 | "apis": "API Reference", 4 | "how-to": "How-to guides", 5 | "migration": "Migration" 6 | } 7 | -------------------------------------------------------------------------------- /docs/pages/apis.mdx: -------------------------------------------------------------------------------- 1 | ## Props 2 | 3 | | Name | Type | Default | Description | 4 | | ---------------------------- | ------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 5 | | `value` | `any` | - | Your input data. Any value, `object`, `Array`, primitive type, even `Map` or `Set`. | 6 | | `rootName` | `string` or `false` | "root" | The name of the root value. | 7 | | `theme` | `"light"`
\| `"dark"`
\| `"auto"`
\| [Base16](https://github.com/chriskempson/base16) | `"light"` | Color theme. | 8 | | `className` | `string` | - | Custom class name. | 9 | | `style` | `CSSProperties` | - | Custom style. | 10 | | `sx` | `SxProps` | - | [The `sx` prop](https://mui.com/system/getting-started/the-sx-prop/) lets you style elements inline, using values from the theme. | 11 | | `indentWidth` | `number` | 3 | Indent width for nested objects | 12 | | `keyRenderer` | `{when: (props) => boolean}` | - | Customize the rendering of key when `keyRenderer.when` returns `true`. Render `null` in `keyRenderer` will cause the colons to be hidden. | 13 | | `valueTypes` | `ValueTypes` | - | Customize the definition of data types. See [Defining Data Types](/how-to/data-types) | 14 | | `enableAdd` | `boolean` \|
`(path, currentValue) => boolean` | `false` | Whether enable add feature. Provide a function to customize this behavior by returning a boolean based on the value and path. | 15 | | `enableDelete` | `boolean` \|
`(path, currentValue) => boolean` | `false` | Whether enable delete feature. Provide a function to customize this behavior by returning a boolean based on the value and path. | 16 | | `enableClipboard` | `boolean` | `false` | Whether enable clipboard feature. | 17 | | `editable` | `boolean` \|
`(path, currentValue) => boolean` | `false` | Whether enable edit feature. Provide a function to customize this behavior by returning a boolean based on the value and path. | 18 | | `onChange` | `(path, oldVal, newVal) => void` | - | Callback when value changed. | 19 | | `onCopy` | `(path, value) => void` | - | Callback when value copied, you can use it to customize the copy behavior.
\*Note: you will have to write the data to the clipboard by yourself. | 20 | | `onSelect` | `(path, value) => void` | - | Callback when value selected. | 21 | | `onAdd` | `(path) => void` | - | Callback when the add button is clicked. This is the function which implements the add feature. Please see the [DEMO](/full) for more details. | 22 | | `onDelete` | `(path) => void` | - | Callback when the delete button is clicked. This is the function which implements the delete feature. Please see the [DEMO](/full) for more details. | 23 | | `defaultInspectDepth` | `number` | 5 | Default inspect depth for nested objects.

_\* If the number is set too large, it could result in performance issues._ | 24 | | `defaultInspectControl` | `(path, currentValue) => boolean` | - | Whether expand or collapse a field by default. Using this will override `defaultInspectDepth`. | 25 | | `maxDisplayLength` | `number` | 30 | Hide items after reaching the count.
`Array` and `Object` will be affected.

_\* If the number is set too large, it could result in performance issues._ | 26 | | `groupArraysAfterLength` | `number` | 100 | Group arrays after reaching the count.
Groups are displayed with bracket notation and can be expanded and collapsed by clicking on the brackets. | 27 | | `collapseStringsAfterLength` | `number` | 50 | Cut off the string after reaching the count. Collapsed strings are followed by an ellipsis.

String content can be expanded and collapsed by clicking on the string value. | 28 | | `objectSortKeys` | `boolean` | `false` | Whether sort keys through `String.prototype.localeCompare()` | 29 | | `quotesOnKeys` | `boolean` | `true` | Whether add quotes on keys. | 30 | | `displayDataTypes` | `boolean` | `true` | Whether display data type labels. | 31 | | `displaySize` | `boolean` \|
`(path, currentValue) => boolean` | `true` | Whether display the size of `Object`, `Array`, `Map` and `Set`. Provide a function to customize this behavior by returning a boolean based on the value and path. | 32 | | `displayComma` | `boolean` | `true` | Whether display commas at the end of the line. | 33 | | `highlightUpdates` | `boolean` | `true` | Whether to highlight updates. | 34 | 35 | ### Mapping from [`mac-s-g/react-json-view`](https://github.com/mac-s-g/react-json-view) 36 | 37 | | Name | Type | Alternative | 38 | | ----------- | --------- | ------------------------------------------------- | 39 | | `name` | `string` | See `rootName` | 40 | | `src` | `any` | See `value` | 41 | | `collapsed` | `boolean` | Set `defaultInspectDepth` to `0` to collapse all. | 42 | 43 | ## Type Declaration 44 | 45 | See [src/type.ts](https://github.com/TexteaInc/json-viewer/blob/main/src/type.ts) 46 | -------------------------------------------------------------------------------- /docs/pages/full/index.tsx: -------------------------------------------------------------------------------- 1 | import type { SvgIconProps } from '@mui/material' 2 | import { 3 | AppBar, 4 | Box, 5 | FormControl, 6 | FormControlLabel, 7 | InputLabel, 8 | MenuItem, 9 | Select, 10 | SvgIcon, 11 | Switch, 12 | TextField, 13 | Toolbar, 14 | Typography 15 | } from '@mui/material' 16 | import type { 17 | DataType, 18 | JsonViewerKeyRenderer, 19 | JsonViewerOnAdd, 20 | JsonViewerOnChange, 21 | JsonViewerOnDelete, 22 | JsonViewerTheme 23 | } from '@textea/json-viewer' 24 | import { 25 | applyValue, 26 | defineDataType, 27 | deleteValue, 28 | JsonViewer, 29 | stringType 30 | } from '@textea/json-viewer' 31 | import Image from 'next/image' 32 | import Link from 'next/link' 33 | import type { FC } from 'react' 34 | import { useCallback, useEffect, useState } from 'react' 35 | 36 | import { ocean } from '../../lib/shared' 37 | 38 | const allowedDomains = ['i.imgur.com'] 39 | 40 | // this url is copied from: https://beta.reactjs.org/learn/passing-props-to-a-component 41 | const avatar = 'https://i.imgur.com/1bX5QH6.jpg' 42 | 43 | function aPlusB (a: number, b: number) { 44 | return a + b 45 | } 46 | const aPlusBConst = function (a: number, b: number) { 47 | return a + b 48 | } 49 | 50 | const loopObject = { 51 | foo: 42, 52 | goo: 'Lorem Ipsum' 53 | } as Record 54 | 55 | loopObject.self = loopObject 56 | 57 | const loopArray = [ 58 | loopObject 59 | ] 60 | 61 | loopArray[1] = loopArray 62 | 63 | const longArray = Array.from({ length: 1000 }).map((_, i) => i) 64 | const map = new Map() 65 | map.set('foo', 1) 66 | map.set('goo', 'hello') 67 | map.set({}, 'world') 68 | 69 | const set = new Set([1, 2, 3]) 70 | 71 | const superLongString = '1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' 72 | 73 | const example = { 74 | avatar, 75 | string: 'Lorem ipsum dolor sit amet', 76 | integer: 42, 77 | url: new URL('https://example.com'), 78 | float: 114.514, 79 | bigint: 123456789087654321n, 80 | undefined, 81 | timer: 0, 82 | date: new Date('Tue Sep 13 2022 14:07:44 GMT-0500 (Central Daylight Time)'), 83 | link: 'http://example.com', 84 | emptyArray: [], 85 | array: [19, 19, 810, 'test', NaN], 86 | emptyObject: {}, 87 | object: { 88 | foo: true, 89 | bar: false, 90 | last: null 91 | }, 92 | emptyMap: new Map(), 93 | map, 94 | emptySet: new Set(), 95 | set, 96 | loopObject, 97 | loopArray, 98 | longArray, 99 | nestedArray: [ 100 | [1, 2], 101 | [3, 4] 102 | ], 103 | superLongString, 104 | function: aPlusB, 105 | constFunction: aPlusBConst, 106 | anonymousFunction: function (a: number, b: number) { 107 | return a + b 108 | }, 109 | shortFunction: (arg1: any, arg2: any) => console.log(arg1, arg2), 110 | shortLongFunction: (arg1: any, arg2: any) => { 111 | console.log(arg1, arg2) 112 | return '123' 113 | }, 114 | string_number: '1234' 115 | } 116 | 117 | const KeyRenderer: JsonViewerKeyRenderer = ({ path }) => { 118 | return ( 119 | "{path.slice(-1)}" 120 | ) 121 | } 122 | KeyRenderer.when = (props) => props.value === 114.514 123 | 124 | const imageDataType = defineDataType({ 125 | is: (value) => { 126 | if (typeof value === 'string') { 127 | try { 128 | const url = new URL(value) 129 | return allowedDomains.includes(url.host) && url.pathname.endsWith('.jpg') 130 | } catch { 131 | return false 132 | } 133 | } 134 | return false 135 | }, 136 | Component: (props) => { 137 | return ( 138 | {props.value} 145 | ) 146 | } 147 | }) 148 | 149 | const LinkIcon = (props: SvgIconProps) => ( 150 | // 151 | 152 | 153 | 154 | 155 | 156 | 157 | ) 158 | 159 | const linkType: DataType = { 160 | ...stringType, 161 | is (value) { 162 | return typeof value === 'string' && value.startsWith('http') 163 | }, 164 | PostComponent: (props) => ( 165 | 172 | 173 | Open 174 | 175 | 176 | 177 | ) 178 | } 179 | 180 | const urlType = defineDataType({ 181 | is: (data) => data instanceof URL, 182 | Component: (props) => { 183 | const url = props.value.toString() 184 | return ( 185 | 195 | {url} 196 | 197 | ) 198 | } 199 | }) 200 | 201 | const IndexPage: FC = () => { 202 | const [indent, setIndent] = useState(3) 203 | const [groupArraysAfterLength, setGroupArraysAfterLength] = useState(100) 204 | const [themeKey, setThemeKey] = useState('light') 205 | const [theme, setTheme] = useState('light') 206 | const [src, setSrc] = useState(() => example) 207 | const [displayDataTypes, setDisplayDataTypes] = useState(true) 208 | const [displaySize, setDisplaySize] = useState(true) 209 | const [displayComma, setDisplayComma] = useState(false) 210 | const [editable, setEditable] = useState(true) 211 | const [highlightUpdates, setHighlightUpdates] = useState(true) 212 | useEffect(() => { 213 | const loop = () => { 214 | setSrc(src => ({ 215 | ...src, 216 | timer: src.timer + 1 217 | })) 218 | } 219 | const id = setInterval(loop, 1000) 220 | return () => clearInterval(id) 221 | }, []) 222 | return ( 223 |
224 | 225 | 226 | 232 | JSON viewer 233 | 234 | 235 | 236 | 249 | setEditable(event.target.checked)} 254 | /> 255 | )} 256 | label='Editable' 257 | /> 258 | setHighlightUpdates(event.target.checked)} 263 | /> 264 | )} 265 | label='Highlight Updates' 266 | /> 267 | setDisplayDataTypes(event.target.checked)} 272 | /> 273 | )} 274 | label='DisplayDataTypes' 275 | /> 276 | setDisplaySize(event.target.checked)} 281 | /> 282 | )} 283 | label='DisplayObjectSize' 284 | /> 285 | setDisplayComma(event.target.checked)} 290 | /> 291 | )} 292 | label='DisplayComma' 293 | /> 294 | { 301 | const indent = parseInt(event.target.value) 302 | if (indent > -1 && indent < 10) { 303 | setIndent(indent) 304 | } 305 | } 306 | } 307 | /> 308 | { 315 | const groupArraysAfterLength = parseInt(event.target.value) 316 | if (groupArraysAfterLength > -1 && groupArraysAfterLength < 500) { 317 | setGroupArraysAfterLength(groupArraysAfterLength) 318 | } 319 | } 320 | } 321 | /> 322 | 325 | Theme 326 | 344 | 345 | 346 | ( 366 | (path) => { 367 | const key = prompt('Key:') 368 | if (key === null) return 369 | const value = prompt('Value:') 370 | if (value === null) return 371 | setSrc(src => applyValue(src, [...path, key], value)) 372 | }, [] 373 | ) 374 | } 375 | onChange={ 376 | useCallback( 377 | (path, oldValue, newValue) => { 378 | setSrc(src => applyValue(src, path, newValue)) 379 | }, [] 380 | ) 381 | } 382 | onDelete={ 383 | useCallback( 384 | (path, value) => { 385 | setSrc(src => deleteValue(src, path, value)) 386 | }, [] 387 | ) 388 | } 389 | sx={{ 390 | paddingX: 2 391 | }} 392 | /> 393 |
394 | ) 395 | } 396 | 397 | export default IndexPage 398 | -------------------------------------------------------------------------------- /docs/pages/how-to/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "data-types": "Defining Data Types", 3 | "built-in-types": "Extend Built-in Data Types", 4 | "styling": "Styling and Theming" 5 | } 6 | -------------------------------------------------------------------------------- /docs/pages/how-to/built-in-types.mdx: -------------------------------------------------------------------------------- 1 | import { JsonViewerToggleBoolean1, JsonViewerToggleBoolean2 } from '../../examples/JsonViewerToggleBoolean' 2 | 3 | # Extend Built-in Data Types 4 | 5 | The following is a list of the built-in data types that can be extended: 6 | 7 | - `stringType` 8 | - `intType` 9 | - `floatType` 10 | - `booleanType` 11 | - `dateType` 12 | - `nanType` 13 | - `nullType` 14 | - `undefinedType` 15 | - `bigIntType` 16 | - `objectType` 17 | - `functionType` 18 | 19 | 20 | ## Example 21 | 22 | ### Displaying Boolean Values as Icons 23 | 24 | Suppose you want to display boolean values as icons (e.g., ✔️ or ❌) instead of the default `true` or `false`. There are two ways to accomplish this: 25 | 26 | 1. Override the `Component` property of the built-in booleanType data type: 27 | 28 | 29 | 30 | 31 | ```tsx 32 | import { JsonViewer, defineDataType, booleanType } from '@textea/json-viewer' 33 | import { Button } from '@mui/material' 34 | 35 | const toggleBoolType = defineDataType({ 36 | ...booleanType, 37 | Component: ({ value }) => ( 38 | {value ? '✔️' : '❌'} 39 | ) 40 | }) 41 | 42 | 49 | ``` 50 | [[Source Code]](https://github.com/TexteaInc/json-viewer/blob/main/docs/examples/JsonViewerToggleBoolean.tsx) 51 | 52 | 2. Create a new data type with `defineEasyType` 53 | 54 | 55 | 56 | ```tsx 57 | import { defineEasyType, booleanType } from '@textea/json-viewer' 58 | 59 | const booleanType = defineEasyType({ 60 | ...booleanType, 61 | type: 'bool', 62 | colorKey: 'base0E', 63 | Renderer: ({ value }) => ( 64 | {value ? '✔️' : '❌'} 65 | ) 66 | }) 67 | 68 | 75 | ``` 76 | [[Source Code]](https://github.com/TexteaInc/json-viewer/blob/main/docs/examples/JsonViewerToggleBoolean.tsx) 77 | 78 | Did you notice the difference between the two examples? 79 | 80 | `defineEasyType` is a helper function that creates data types with type labels and colors. So you only need to care about how the value should be rendered. All other details will be automatically handled. 81 | -------------------------------------------------------------------------------- /docs/pages/how-to/data-types.mdx: -------------------------------------------------------------------------------- 1 | import { Tab, Tabs } from 'nextra-theme-docs' 2 | import JsonViewerWithURL from '../../examples/JsonViewerWithURL' 3 | import JsonViewerWithImage from '../../examples/JsonViewerWithImage' 4 | import JsonViewerWithWidget from '../../examples/JsonViewerWithWidget' 5 | import JsonViewerCustomizeDate from '../../examples/JsonViewerCustomizeDate' 6 | 7 | # Defining data types for any data 8 | 9 | The `defineDataType` function allows you to define custom data types for any data structure. This is useful for: 10 | 11 | - Adding support for data types not supported by the library. 12 | - Customizing or extending the appearance and functionality of existing data types. 13 | 14 | ## Introduction 15 | 16 | To define a data type using `defineDataType`, you provide an object with various properties, such as `is`, `Component`, `PreComponent`, `PostComponent`, and `Editor`. These properties enable you to specify how to match values of the data type, how to render them, and how to edit them. 17 | 18 | Before we dive into the details of defining a data type, let's take a closer look at the object that you'll need to provide to `defineDataType`. 19 | 20 | #### `is` - The Type Matcher 21 | 22 | The `is` function takes a value and a path and returns true if the value belongs to the data type being defined. 23 | 24 | #### `Component` - The Renderer 25 | 26 | The `Component` prop is a React component that renders the value of the data type. It receives a `DataItemProps` object as a `prop`, which includes the following: 27 | 28 | - `props.path` - The path to the value. 29 | - `props.value` - The value to render. 30 | - `props.inspect` - A Boolean flag indicating whether the value is being inspected (expanded). 31 | - `props.setInspect` - A function that can be used to toggle the inspect state. 32 | 33 | #### `PreComponent` and `PostComponent` 34 | 35 | For advanced use cases, you can provide `PreComponent` and `PostComponent` to render content before and after the value, respectively. This is useful if you want to add a button or implement an expandable view. 36 | 37 | #### `Editor` 38 | 39 | To enable editing for a data type, you need to provide `serialize` and `deserialize` functions to convert the value to and from a string representation. You can then use the `Editor` component to provide a custom editor for the stringified value. When the user edits the value, it will be parsed using `deserialize`, and the result will be passed to the `onChange` callback. 40 | - `props.path` - The path to the value. 41 | - `props.value` - The value to edit. 42 | - `props.setValue` - A function that can be used to update the value. 43 | - `props.abortEditing` - A function that can be used to abort editing. 44 | - `props.commitEditing` - A function that can be used to commit the value and finish editing. 45 | 46 | ## Examples 47 | 48 | ### Adding support for image 49 | 50 | Suppose you have a JSON object that includes an image URL: 51 | 52 | ```json 53 | { 54 | "image": "https://i.imgur.com/1bX5QH6.jpg" 55 | } 56 | ``` 57 | 58 | If you want to preview images directly in your JSON, you can define a data type for images: 59 | 60 | 61 | 62 | 63 | ```jsx 64 | import { defineDataType } from '@textea/json-viewer' 65 | 66 | const imageType = defineDataType({ 67 | is: (value) => { 68 | if (typeof value !== 'string') return false 69 | try { 70 | const url = new URL(value) 71 | return url.pathname.endsWith('.jpg') 72 | } catch { 73 | return false 74 | } 75 | }, 76 | Component: (props) => { 77 | return ( 78 | {props.value} 85 | ) 86 | } 87 | }) 88 | ``` 89 | 90 | 91 | 92 | 93 | ```tsx 94 | import { defineDataType } from '@textea/json-viewer' 95 | 96 | const imageType = defineDataType({ 97 | is: (value) => { 98 | if (typeof value !== 'string') return false 99 | try { 100 | const url = new URL(value) 101 | return url.pathname.endsWith('.jpg') 102 | } catch { 103 | return false 104 | } 105 | }, 106 | Component: (props) => { 107 | return ( 108 | {props.value} 115 | ) 116 | } 117 | }) 118 | ``` 119 | 120 | 121 | 122 | 123 | And then use it like this: 124 | 125 | ```jsx {3,5} 126 | 132 | ``` 133 | 134 |
135 | 136 | 137 | ### Adding support for `URL` 138 | 139 | Let's add support for the [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) API. 140 | 141 | 142 | 143 | ```jsx 144 | import { defineDataType } from '@textea/json-viewer' 145 | 146 | const urlType = defineDataType({ 147 | is: (value) => value instanceof URL, 148 | Component: (props) => { 149 | const url = props.value.toString() 150 | return ( 151 | 161 | {url} 162 | 163 | ) 164 | } 165 | }) 166 | ``` 167 | 168 | 169 | 170 | ```tsx 171 | import { defineDataType } from '@textea/json-viewer' 172 | 173 | const urlType = defineDataType({ 174 | is: (value) => value instanceof URL, 175 | Component: (props) => { 176 | const url = props.value.toString() 177 | return ( 178 | 188 | {url} 189 | 190 | ) 191 | } 192 | }) 193 | ``` 194 | 195 | 196 | 197 | And then use it like this: 198 | 199 | ```jsx {4,6} 200 | 207 | ``` 208 | 209 | It should looks like this 🎉 210 | 211 |
212 | 213 | 214 | ### Adding widgets to the value 215 | 216 | Sometimes you might want to add a button to the value. 217 | 218 | 219 | 220 | In this example, we will **extend the built-in `stringType`** and add a button to open the link in a new tab. 221 | 222 | 223 | 224 | ```jsx 225 | import { defineDataType, JsonViewer, stringType } from '@textea/json-viewer' 226 | import { Button } from '@mui/material' 227 | 228 | const linkType = defineDataType({ 229 | ...stringType, 230 | is (value) { 231 | return typeof value === 'string' && value.startsWith('http') 232 | }, 233 | PostComponent: (props) => ( 234 | 248 | ) 249 | }) 250 | ``` 251 | 252 | 253 | 254 | ```tsx 255 | import { defineDataType, JsonViewer, stringType } from '@textea/json-viewer' 256 | import { Button } from '@mui/material' 257 | 258 | const linkType = defineDataType({ 259 | ...stringType, 260 | is (value) { 261 | return typeof value === 'string' && value.startsWith('http') 262 | }, 263 | PostComponent: (props) => ( 264 | 278 | ) 279 | }) 280 | ``` 281 | 282 | 283 | 284 | ### Customize the `Date` format 285 | 286 | By default, `Date` values are displayed using the `toLocaleString` method. You will learn how to create a custom data type for `Date` and display it in a different format. We will use the enhanced `defineEasyType` function to simplify the process. 287 | 288 | 289 | 290 | ```jsx 291 | import { defineEasyType } from '@textea/json-viewer' 292 | 293 | const myDateType = defineEasyType({ 294 | is: (value) => value instanceof Date, 295 | type: 'date', 296 | colorKey: 'base0D', 297 | Renderer: ({ value }) => <>{value.toISOString().split('T')[0]} 298 | }) 299 | ``` 300 | 301 | 302 | 303 | ```tsx 304 | import { defineEasyType } from '@textea/json-viewer' 305 | 306 | const myDateType = defineEasyType({ 307 | is: (value) => value instanceof Date, 308 | type: 'date', 309 | colorKey: 'base0D', 310 | Renderer: ({ value }) => <>{value.toISOString().split('T')[0]} 311 | }) 312 | ``` 313 | 314 | 315 | 316 | 317 | -------------------------------------------------------------------------------- /docs/pages/how-to/styling.mdx: -------------------------------------------------------------------------------- 1 | # Customize the appearance 2 | 3 | You can customize the appearance by the following ways. 4 | 5 | ## Style 6 | 7 | You can customize the style by passing `style`, `className` and [`sx`](https://mui.com/system/getting-started/the-sx-prop/) props. 8 | 9 | ```jsx {2} 10 | 11 | ``` 12 | 13 | ## Theme 14 | 15 | Pass `theme` prop to `JsonViewer` component. The default value is `light`. 16 | 17 | #### Options 18 | 19 | Available values are `light`, `dark` and `auto`. If you pass `auto`, the theme will be changed automatically according to the system theme. 20 | 21 | ```jsx {2} 22 | 25 | ``` 26 | 27 | Sets of classes are binded to the component root to reflect the current theme. You can also use this to customize the appearance. 28 | 29 | - `json-viewer-theme-light` 30 | - `json-viewer-theme-dark` 31 | - `json-viewer-theme-custom` when a theme object being passed 32 | 33 | #### Base 16 Theme 34 | 35 | You can also pass [Base 16](https://github.com/chriskempson/base16) theme object to fine-tune the appearance. 36 | 37 | ```tsx filename="theme/ocean.ts" 38 | import type { NamedColorspace } from '@textea/json-viewer' 39 | 40 | export const ocean: NamedColorspace = { 41 | scheme: 'Ocean', 42 | author: 'Chris Kempson (http://chriskempson.com)', 43 | base00: '#2b303b', 44 | base01: '#343d46', 45 | base02: '#4f5b66', 46 | base03: '#65737e', 47 | base04: '#a7adba', 48 | base05: '#c0c5ce', 49 | base06: '#dfe1e8', 50 | base07: '#eff1f5', 51 | base08: '#bf616a', 52 | base09: '#d08770', 53 | base0A: '#ebcb8b', 54 | base0B: '#a3be8c', 55 | base0C: '#96b5b4', 56 | base0D: '#8fa1b3', 57 | base0E: '#b48ead', 58 | base0F: '#ab7967' 59 | } 60 | 61 | 64 | ``` 65 | 66 | ## The good old css way 67 | 68 | Different part of the dom structure will have class names like `data-object-start`, `json-type-label`, `json-function-start` and so on. You can use these class names to customize the detailed appearance. 69 | 70 | This is the list of class names. 🧐 71 | 72 | - `data-key` 73 | - `data-key-pair` 74 | - `data-key-key` 75 | - `data-key-colon` 76 | - `data-key-toggle-expanded` 77 | - `data-key-toggle-collapsed` 78 | - `data-type-label` 79 | - `data-object` 80 | - `data-object-start` 81 | - `data-object-body` 82 | - `data-object-end` 83 | - `data-function` 84 | - `data-function-start` 85 | - `data-function-body` 86 | - `data-function-end` 87 | - `data-value-fallback` 88 | 89 | ```css 90 | .json-viewer .data-object-start { 91 | color: red; 92 | } 93 | ``` 94 | -------------------------------------------------------------------------------- /docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import Button from '@mui/material/Button' 3 | import { Tab, Tabs } from 'nextra-theme-docs' 4 | import { JsonViewerPreview } from '../examples/JsonViewerPreview' 5 | 6 | # @textea/json-viewer 7 | 8 | ## Overview 9 | 10 | > **Makes data visualization a breeze!** 11 | 12 | `@textea/json-viewer` allows you to display JSON data in a readable and user-friendly format. But it's not just limited to JSON - you can use it to view **ANY** kind of data. The library is highly customizable and supports 100% TypeScript, making it easy to modify and tailor to your specific needs. 13 | 14 | ## Getting Started 15 | 16 | ### Installation 17 | 18 | `@textea/json-viewer` is using [Material-UI](https://mui.com/) as the base component library, so you need to install it and its peer dependencies first. 19 | 20 | 21 | ``` npm install @textea/json-viewer @mui/material @emotion/react @emotion/styled ``` 22 | ``` yarn add @textea/json-viewer @mui/material @emotion/react @emotion/styled ``` 23 | ``` pnpm add @textea/json-viewer @mui/material @emotion/react @emotion/styled ``` 24 | 25 | 26 | You can also use it directly from a CDN: 27 | 28 | ```html {5,8-12} filename="index.html" 29 | 30 | 31 | 32 |
33 | 34 | 35 | 42 | 43 | 44 | ``` 45 | 46 | ### Basic Example 47 | 48 | ```tsx 49 | import { JsonViewer } from '@textea/json-viewer' 50 | 51 | const object = { 52 | /* my json object */ 53 | } 54 | const Component = () => 55 | ``` 56 | 57 | 70 | 71 | ### Advanced Example 72 | 73 |
74 | 77 | 78 | Check the [source code](https://github.com/TexteaInc/json-viewer/blob/main/docs/pages/full/index.tsx) for more details. 79 | 80 | ## API 81 | 82 | See the documentation below for a complete reference to all of the props available 83 | 84 | - [API Reference](/apis) 85 | 86 | ## Contributors 87 | 88 | 89 | 90 | 91 | 92 | ## Acknowledge 93 | 94 | This package is originally based on [mac-s-g/react-json-view](https://github.com/mac-s-g/react-json-view). 95 | 96 | Also thanks open source projects that make this possible. 97 | 98 | ## Sponsoring services 99 | 100 | ![Netlify](https://www.netlify.com/v3/img/components/full-logo-light.svg) 101 | 102 | [Netlify](https://www.netlify.com/) lets us distribute the [site](https://viewer.textea.io). 103 | -------------------------------------------------------------------------------- /docs/pages/migration/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "migration-v3": "Migrating to v3", 3 | "migration-v4": "Migrating to v4" 4 | } 5 | -------------------------------------------------------------------------------- /docs/pages/migration/migration-v3.mdx: -------------------------------------------------------------------------------- 1 | # Migrating from v2 to v3 2 | 3 | ### Update version 4 | 5 | ```bash 6 | npm install @textea/json-viewer@^3.0.0 7 | ``` 8 | 9 | #### Install peer dependencies 10 | 11 | This package is using [Material-UI](https://mui.com/) as the base component library, so you need to install it and its peer dependencies. 12 | Starting from v3, these dependencies are no longer included in the package's dependencies. 13 | 14 | ```bash 15 | npm install @mui/material @emotion/react @emotion/styled 16 | ``` 17 | 18 | ### Breaking changes 19 | 20 | #### Check browser compatibility 21 | 22 | This package was set to support `ES5` by default, but it's no longer the case.\ 23 | Since V3, as this package is using `Material-UI`, we have adjusted the browser compatibility to match the [Material-UI's one](https://mui.com/getting-started/supported-platforms/). 24 | 25 | #### Use `defineDataType` instead of `createDataType` 26 | 27 | `serialize` and `deserialize` have been added to datatype to support editing feature on any data type. 28 | 29 | As the result, `createDataType` has been renamed to `defineDataType` and the signature has been changed to accept an object instead of a long list of arguments. For more information, please refer to [Defining data types](/data-types.mdx). 30 | 31 | ```diff 32 | - createDataType( 33 | - (value) => typeof value === 'string' && value.startsWith('https://i.imgur.com'), 34 | - (props) => {props.value} 35 | - ) 36 | + defineDataType({ 37 | + is: (value) => typeof value === 'string' && value.startsWith('https://i.imgur.com'), 38 | + Component: (props) => {props.value} 39 | + }) 40 | ``` 41 | 42 | #### Rename `displayObjectSize` to `displaySize` 43 | 44 | `displayObjectSize` has been renamed to `displaySize` to describe the prop's purpose more accurately. 45 | 46 | ```diff 47 | 52 | ``` 53 | 54 | Now you can provide a function to customize this behavior by returning a boolean based on the value and path. 55 | 56 | ```jsx {2-6} 57 | { 59 | if (Array.isArray(value)) return false 60 | if (value instanceof Map) return true 61 | return true 62 | }} 63 | value={value} 64 | /> 65 | ``` 66 | -------------------------------------------------------------------------------- /docs/pages/migration/migration-v4.mdx: -------------------------------------------------------------------------------- 1 | # Migrating from v3 to v4 2 | 3 | ### Update version 4 | 5 | ```bash 6 | npm install @textea/json-viewer@^4.0.0 7 | npm install @mui/material@^6.0.0 8 | ``` 9 | 10 | For `MUI` users, you should also check the [MUI v6 migration guide](https://mui.com/material-ui/migration/upgrade-to-v6/). 11 | 12 | ### Breaking changes 13 | 14 | #### Check browser compatibility 15 | 16 | This package was set to support `ES5` by default, but it's no longer the case.\ 17 | Since V3 and V4, as this package is using `Material-UI`, we have adjusted the browser compatibility to match the [Material-UI's one](https://mui.com/getting-started/supported-platforms/). 18 | 19 | MUI 6 also removed support for IE 11, so this package will no longer support IE 11. If you need to support IE 11, you can stay on v3. Note that it will not receive updates or bug fixes in the future. 20 | 21 | #### Minimum TypeScript version 22 | 23 | The minimum supported version of TypeScript has been increased from `v3.5` to `v4.7`. 24 | 25 | #### Use `defineDataType` instead of `createDataType` 26 | 27 | `createDataType` has been deprecated in v3 and removed in v4. Use `defineDataType` instead. 28 | 29 | For more information, please refer to [V3 Migration Guide](/migration/migration-v3#use-definedatatype-instead-of-createdatatype). 30 | -------------------------------------------------------------------------------- /docs/theme.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | logo: '@textea/json-viewer', 3 | docsRepositoryBase: 'https://github.com/TexteaInc/json-viewer/tree/main/docs', 4 | project: { 5 | link: 'https://github.com/TexteaInc/json-viewer' 6 | }, 7 | navigation: { 8 | prev: true, 9 | next: true 10 | }, 11 | editLink: { 12 | text: 'Edit this page on GitHub' 13 | }, 14 | footer: { 15 | text: `MIT ${new Date().getFullYear()} © Textea, Inc.` 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@textea/json-viewer": ["../src/index"] 5 | }, 6 | "jsx": "preserve", 7 | "incremental": true, 8 | "noEmit": true, 9 | "target": "ES2020", 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textea/json-viewer", 3 | "description": "Interactive Json Viewer, but not only a json viewer", 4 | "packageManager": "pnpm@9.10.0", 5 | "version": "4.0.1", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/TexteaInc/json-viewer.git" 10 | }, 11 | "author": "himself65 ", 12 | "bugs": "https://github.com/TexteaInc/json-viewer/issues", 13 | "homepage": "https://github.com/TexteaInc/json-viewer#readme", 14 | "keywords": [ 15 | "react-18", 16 | "react", 17 | "react-json", 18 | "react-json-viewer", 19 | "array-viewer", 20 | "component", 21 | "interactive", 22 | "interactive-json", 23 | "json", 24 | "json-component", 25 | "json-display", 26 | "json-tree", 27 | "json-view", 28 | "json-viewer", 29 | "json-inspector", 30 | "json-tree", 31 | "tree", 32 | "tree-view", 33 | "treeview" 34 | ], 35 | "types": "dist/index.d.ts", 36 | "jsdelivr": "dist/browser.js", 37 | "unpkg": "dist/browser.js", 38 | "main": "dist/index.js", 39 | "module": "dist/index.mjs", 40 | "exports": { 41 | ".": { 42 | "types": "./dist/index.d.ts", 43 | "import": "./dist/index.mjs", 44 | "require": "./dist/index.js" 45 | } 46 | }, 47 | "files": [ 48 | "dist" 49 | ], 50 | "scripts": { 51 | "dev": "pnpm --filter \"@textea/json-viewer-docs\" run dev", 52 | "test": "vitest run", 53 | "test:watch": "vitest", 54 | "coverage": "vitest run --coverage", 55 | "postinstall": "husky install", 56 | "prepack": "pinst --disable", 57 | "postpack": "pinst --enable", 58 | "lint": "npx eslint . --ext .ts,.tsx,.js,.jsx --cache --fix", 59 | "lint:ci": "npx eslint . --ext .ts,.tsx,.js,.jsx --cache --max-warnings 0", 60 | "build": "tsc && rollup -c rollup.config.ts --configPlugin swc3" 61 | }, 62 | "dependencies": { 63 | "clsx": "^2.1.1", 64 | "copy-to-clipboard": "^3.3.3", 65 | "zustand": "^4.5.5" 66 | }, 67 | "lint-staged": { 68 | "!*.{ts,tsx,js,jsx}": "prettier --write --ignore-unknown", 69 | "*.{ts,tsx,js,jsx}": "npx eslint --cache --fix" 70 | }, 71 | "peerDependencies": { 72 | "@emotion/react": "^11", 73 | "@emotion/styled": "^11", 74 | "@mui/material": "^6", 75 | "react": "^17 || ^18", 76 | "react-dom": "^17 || ^18" 77 | }, 78 | "devDependencies": { 79 | "@commitlint/cli": "^19.6.0", 80 | "@commitlint/config-conventional": "^19.6.0", 81 | "@emotion/react": "^11.13.5", 82 | "@emotion/styled": "^11.13.5", 83 | "@mui/material": "^6.1.10", 84 | "@rollup/plugin-alias": "^5.1.1", 85 | "@rollup/plugin-commonjs": "^28.0.1", 86 | "@rollup/plugin-node-resolve": "^15.3.0", 87 | "@rollup/plugin-replace": "^6.0.1", 88 | "@swc/core": "^1.10.0", 89 | "@swc/helpers": "^0.5.15", 90 | "@testing-library/dom": "^10.4.0", 91 | "@testing-library/react": "^16.1.0", 92 | "@types/node": "^20.17.9", 93 | "@types/react": "^18.3.11", 94 | "@types/react-dom": "^18.3.1", 95 | "@typescript-eslint/eslint-plugin": "^8.17.0", 96 | "@typescript-eslint/parser": "^8.17.0", 97 | "@vitejs/plugin-react": "^4.3.4", 98 | "@vitest/coverage-v8": "^2.1.8", 99 | "eslint": "^8.57.1", 100 | "eslint-config-standard": "^17.1.0", 101 | "eslint-plugin-import": "npm:eslint-plugin-i@^2.29.1", 102 | "eslint-plugin-n": "^17.14.0", 103 | "eslint-plugin-promise": "^7.2.1", 104 | "eslint-plugin-react": "^7.37.2", 105 | "eslint-plugin-react-hooks": "^4.6.2", 106 | "eslint-plugin-simple-import-sort": "^12.1.1", 107 | "eslint-plugin-unused-imports": "^4.1.4", 108 | "expect-type": "^0.20.0", 109 | "husky": "9.1.7", 110 | "jsdom": "^25.0.1", 111 | "lint-staged": "^15.2.10", 112 | "pinst": "^3.0.0", 113 | "prettier": "^3.4.2", 114 | "react": "^18.3.1", 115 | "react-dom": "^18.3.1", 116 | "rollup": "^4.28.1", 117 | "rollup-plugin-dts": "^6.1.1", 118 | "rollup-plugin-swc3": "^0.12.1", 119 | "ts-node": "^10.9.2", 120 | "typescript": "^5.7.2", 121 | "vite": "^5.4.9", 122 | "vitest": "^2.1.8" 123 | }, 124 | "overrides": { 125 | "browserslist": "4.23.3", 126 | "caniuse-lite": "1.0.30001660" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - '.' 3 | - 'docs' 4 | -------------------------------------------------------------------------------- /public/avatar-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TexteaInc/json-viewer/8049f5705930e88108a1a9480104592bbb1721ab/public/avatar-preview.png -------------------------------------------------------------------------------- /public/ocean-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TexteaInc/json-viewer/8049f5705930e88108a1a9480104592bbb1721ab/public/ocean-theme.png -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "419cf32454d972d1a0802974bb5d8b9bf9c5704e", 3 | "release_type": "node", 4 | "group-pull-request-title-pattern": "chore: release ${version}", 5 | "monorepo-tags": true, 6 | "include-component-in-tag": true, 7 | "packages": { 8 | ".": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { basename, resolve } from 'node:path' 3 | 4 | import commonjs from '@rollup/plugin-commonjs' 5 | import { nodeResolve } from '@rollup/plugin-node-resolve' 6 | import replace from '@rollup/plugin-replace' 7 | import type { 8 | ModuleFormat, 9 | OutputOptions, 10 | RollupCache, 11 | RollupOptions 12 | } from 'rollup' 13 | import dts from 'rollup-plugin-dts' 14 | import { defineRollupSwcOption, swc } from 'rollup-plugin-swc3' 15 | import { fileURLToPath } from 'url' 16 | 17 | let cache: RollupCache 18 | 19 | const dtsOutput = new Set<[string, string]>() 20 | 21 | const outputDir = fileURLToPath(new URL('dist', import.meta.url)) 22 | 23 | const external = [ 24 | '@mui/material', 25 | '@mui/material/styles', 26 | 'copy-to-clipboard', 27 | 'zustand', 28 | 'react', 29 | 'react/jsx-runtime', 30 | 'react-dom', 31 | 'react-dom/client' 32 | ] 33 | const outputMatrix = ( 34 | name: string, format: ModuleFormat[]): OutputOptions[] => { 35 | const baseName = basename(name) 36 | return format.flatMap(format => ({ 37 | file: resolve(outputDir, `${baseName}.${format === 'es' ? 'm' : ''}js`), 38 | sourcemap: false, 39 | name: 'JsonViewer', 40 | format, 41 | banner: `/// `, 42 | globals: external.reduce((object, module) => { 43 | object[module] = module 44 | return object 45 | }, {} as Record) 46 | })) 47 | } 48 | 49 | const buildMatrix = (input: string, output: string, config: { 50 | format: ModuleFormat[] 51 | browser: boolean 52 | dts: boolean 53 | }): RollupOptions => { 54 | if (config.dts) { 55 | dtsOutput.add([input, output]) 56 | } 57 | return { 58 | input, 59 | output: outputMatrix(output, config.format), 60 | cache, 61 | external: config.browser ? [] : external, 62 | plugins: [ 63 | config.browser && replace({ 64 | preventAssignment: true, 65 | 'process.env.NODE_ENV': JSON.stringify('production'), 66 | 'typeof window': JSON.stringify('object') 67 | }), 68 | commonjs(), 69 | nodeResolve(), 70 | swc(defineRollupSwcOption({ 71 | jsc: { 72 | externalHelpers: true, 73 | parser: { 74 | syntax: 'typescript', 75 | tsx: true 76 | }, 77 | transform: { 78 | react: { 79 | runtime: 'automatic' 80 | } 81 | } 82 | }, 83 | env: { 84 | // we have to copy this configuration because swc is not handling `.browserslistrc` properly 85 | // see https://github.com/swc-project/swc/issues/3365 86 | targets: 'and_chr 91,and_ff 89,and_qq 10.4,and_uc 12.12,android 91,baidu 7.12,chrome 90,edge 91,firefox 78,ios_saf 12.2,kaios 2.5,op_mini all,op_mob 76' 87 | }, 88 | tsconfig: false 89 | })) 90 | ], 91 | /** 92 | * Ignore "use client" waning 93 | * @see {@link https://github.com/TanStack/query/pull/5161#issuecomment-1477389761 Preserve 'use client' directives} 94 | */ 95 | onwarn (warning, warn) { 96 | if ( 97 | warning.code === 'MODULE_LEVEL_DIRECTIVE' && 98 | warning.message.includes('"use client"') 99 | ) { 100 | return 101 | } 102 | warn(warning) 103 | } 104 | } 105 | } 106 | 107 | const dtsMatrix = (): RollupOptions[] => { 108 | return [...dtsOutput.values()].flatMap(([input, output]) => ({ 109 | input, 110 | cache, 111 | output: { 112 | file: resolve(outputDir, `${output}.d.ts`), 113 | format: 'es' 114 | }, 115 | plugins: [ 116 | dts() 117 | ] 118 | })) 119 | } 120 | 121 | const build: RollupOptions[] = [ 122 | buildMatrix('./src/index.tsx', 'index', { 123 | format: ['es', 'umd'], 124 | browser: false, 125 | dts: true 126 | }), 127 | buildMatrix('./src/browser.tsx', 'browser', { 128 | format: ['es', 'umd'], 129 | browser: true, 130 | dts: true 131 | }), 132 | ...dtsMatrix() 133 | ] 134 | 135 | export default build 136 | -------------------------------------------------------------------------------- /src/browser.tsx: -------------------------------------------------------------------------------- 1 | import type { Root } from 'react-dom/client' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | import { JsonViewer as JsonViewerComponent } from '.' 5 | import * as dataTypes from './components/DataTypes' 6 | import * as base16 from './theme/base16' 7 | import type { JsonViewerProps } from './type' 8 | import { applyValue, defineDataType, deleteValue, isCycleReference, safeStringify } from './utils' 9 | 10 | const getElementFromConfig = (el?: string | Element) => (el 11 | ? (typeof el === 'string' ? document.querySelector(el) : el) 12 | : document.getElementById('json-viewer')) 13 | 14 | export default class JsonViewer { 15 | private props: JsonViewerProps 16 | private root?: Root 17 | 18 | static Component = JsonViewerComponent 19 | static DataTypes = dataTypes 20 | static Themes = base16 21 | static Utils = { 22 | applyValue, 23 | defineDataType, 24 | deleteValue, 25 | isCycleReference, 26 | safeStringify 27 | } 28 | 29 | constructor (props: JsonViewerProps) { 30 | this.props = props 31 | } 32 | 33 | render (el?: string | Element) { 34 | const container = getElementFromConfig(el) 35 | 36 | if (container) { 37 | this.root = createRoot(container) 38 | this.root.render() 39 | } 40 | } 41 | 42 | destroy () { 43 | if (this.root) { 44 | this.root.unmount() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/DataKeyPair.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import type { ComponentProps, FC, MouseEvent } from 'react' 3 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 4 | 5 | import { useTextColor } from '../hooks/useColor' 6 | import { useClipboard } from '../hooks/useCopyToClipboard' 7 | import { useInspect } from '../hooks/useInspect' 8 | import { useJsonViewerStore } from '../stores/JsonViewerStore' 9 | import { useTypeComponents } from '../stores/typeRegistry' 10 | import type { DataItemProps } from '../type' 11 | import { copyString, getValueSize, isPlainObject } from '../utils' 12 | import { 13 | AddBoxIcon, 14 | CheckIcon, 15 | ChevronRightIcon, 16 | CloseIcon, 17 | ContentCopyIcon, 18 | DeleteIcon, 19 | EditIcon, 20 | ExpandMoreIcon 21 | } from './Icons' 22 | import { DataBox } from './mui/DataBox' 23 | 24 | export type DataKeyPairProps = { 25 | value: unknown 26 | prevValue?: unknown 27 | nestedIndex?: number 28 | editable?: boolean 29 | path: (string | number)[] 30 | last: boolean 31 | } 32 | 33 | type IconBoxProps = ComponentProps 34 | 35 | const IconBox: FC = (props) => ( 36 | 45 | ) 46 | 47 | export const DataKeyPair: FC = (props) => { 48 | const { value, prevValue, path, nestedIndex, last } = props 49 | const { Component, PreComponent, PostComponent, Editor, serialize, deserialize } = useTypeComponents(value, path) 50 | 51 | const propsEditable = props.editable ?? undefined 52 | const storeEditable = useJsonViewerStore(store => store.editable) 53 | const editable = useMemo(() => { 54 | if (storeEditable === false) { 55 | return false 56 | } 57 | if (propsEditable === false) { 58 | // props.editable is false which means we cannot provide the suitable way to edit it 59 | return false 60 | } 61 | if (typeof storeEditable === 'function') { 62 | return !!storeEditable(path, value) 63 | } 64 | return storeEditable 65 | }, [path, propsEditable, storeEditable, value]) 66 | const [tempValue, setTempValue] = useState('') 67 | const depth = path.length 68 | const key = path[depth - 1] 69 | const hoverPath = useJsonViewerStore(store => store.hoverPath) 70 | const isHover = useMemo(() => { 71 | return hoverPath && path.every( 72 | (value, index) => value === hoverPath.path[index] && nestedIndex === 73 | hoverPath.nestedIndex) 74 | }, [hoverPath, path, nestedIndex]) 75 | const setHover = useJsonViewerStore(store => store.setHover) 76 | const root = useJsonViewerStore(store => store.value) 77 | const [inspect, setInspect] = useInspect(path, value, nestedIndex) 78 | const [editing, setEditing] = useState(false) 79 | const onChange = useJsonViewerStore(store => store.onChange) 80 | const keyColor = useTextColor() 81 | const numberKeyColor = useJsonViewerStore(store => store.colorspace.base0C) 82 | const highlightColor = useJsonViewerStore(store => store.colorspace.base0A) 83 | const displayComma = useJsonViewerStore(store => store.displayComma) 84 | const quotesOnKeys = useJsonViewerStore(store => store.quotesOnKeys) 85 | const rootName = useJsonViewerStore(store => store.rootName) 86 | const isRoot = root === value 87 | const isNumberKey = Number.isInteger(Number(key)) 88 | 89 | const storeEnableAdd = useJsonViewerStore(store => store.enableAdd) 90 | const onAdd = useJsonViewerStore(store => store.onAdd) 91 | const enableAdd = useMemo(() => { 92 | if (!onAdd || nestedIndex !== undefined) return false 93 | 94 | if (storeEnableAdd === false) { 95 | return false 96 | } 97 | if (propsEditable === false) { 98 | // props.editable is false which means we cannot provide the suitable way to edit it 99 | return false 100 | } 101 | if (typeof storeEnableAdd === 'function') { 102 | return !!storeEnableAdd(path, value) 103 | } 104 | 105 | if (Array.isArray(value) || isPlainObject(value)) { 106 | return true 107 | } 108 | 109 | return false 110 | }, [onAdd, nestedIndex, path, storeEnableAdd, propsEditable, value]) 111 | 112 | const storeEnableDelete = useJsonViewerStore(store => store.enableDelete) 113 | const onDelete = useJsonViewerStore(store => store.onDelete) 114 | const enableDelete = useMemo(() => { 115 | if (!onDelete || nestedIndex !== undefined) return false 116 | 117 | if (isRoot) { 118 | // don't allow delete root 119 | return false 120 | } 121 | if (storeEnableDelete === false) { 122 | return false 123 | } 124 | if (propsEditable === false) { 125 | // props.editable is false which means we cannot provide the suitable way to edit it 126 | return false 127 | } 128 | if (typeof storeEnableDelete === 'function') { 129 | return !!storeEnableDelete(path, value) 130 | } 131 | return storeEnableDelete 132 | }, [onDelete, nestedIndex, isRoot, path, storeEnableDelete, propsEditable, value]) 133 | 134 | const enableClipboard = useJsonViewerStore(store => store.enableClipboard) 135 | const { copy, copied } = useClipboard() 136 | 137 | const highlightUpdates = useJsonViewerStore(store => store.highlightUpdates) 138 | const isHighlight = useMemo(() => { 139 | if (!highlightUpdates || prevValue === undefined) return false 140 | 141 | // highlight if value type changed 142 | if (typeof value !== typeof prevValue) { 143 | return true 144 | } 145 | 146 | if (typeof value === 'number') { 147 | // notice: NaN !== NaN 148 | if (isNaN(value) && isNaN(prevValue as number)) return false 149 | return value !== prevValue 150 | } 151 | 152 | // highlight if isArray changed 153 | if (Array.isArray(value) !== Array.isArray(prevValue)) { 154 | return true 155 | } 156 | 157 | // not highlight object/function 158 | // deep compare they will be slow 159 | if (typeof value === 'object' || typeof value === 'function') { 160 | return false 161 | } 162 | 163 | // highlight if not equal 164 | if (value !== prevValue) { 165 | return true 166 | } 167 | 168 | return false 169 | }, [highlightUpdates, prevValue, value]) 170 | const highlightContainer = useRef() 171 | useEffect(() => { 172 | if (highlightContainer.current && isHighlight && 'animate' in highlightContainer.current) { 173 | highlightContainer.current.animate( 174 | [ 175 | { backgroundColor: highlightColor }, 176 | { backgroundColor: '' } 177 | ], 178 | { 179 | duration: 1000, 180 | easing: 'ease-in' 181 | } 182 | ) 183 | } 184 | }, [highlightColor, isHighlight, prevValue, value]) 185 | 186 | const startEditing = useCallback((event: MouseEvent) => { 187 | event.preventDefault() 188 | if (serialize) setTempValue(serialize(value)) 189 | setEditing(true) 190 | }, [serialize, value]) 191 | 192 | const abortEditing = useCallback(() => { 193 | setEditing(false) 194 | setTempValue('') 195 | }, [setEditing, setTempValue]) 196 | 197 | const commitEditing = useCallback((newValue: string) => { 198 | setEditing(false) 199 | if (!deserialize) return 200 | 201 | try { 202 | onChange(path, value, deserialize(newValue)) 203 | } catch { 204 | // do nothing when deserialize failed 205 | } 206 | }, [setEditing, deserialize, onChange, path, value]) 207 | 208 | const actionIcons = useMemo(() => { 209 | if (editing) { 210 | return ( 211 | <> 212 | 213 | 217 | 218 | 219 | commitEditing(tempValue)} 222 | /> 223 | 224 | 225 | ) 226 | } 227 | return ( 228 | <> 229 | {enableClipboard && ( 230 | { 232 | event.preventDefault() 233 | try { 234 | copy(path, value, copyString) 235 | } catch (e) { 236 | // in some case, this will throw error 237 | // fixme: `useAlert` hook 238 | console.error(e) 239 | } 240 | }} 241 | > 242 | { 243 | copied 244 | ? 245 | : 246 | } 247 | 248 | )} 249 | {(Editor && editable && serialize && deserialize) && 250 | ( 251 | 254 | 255 | 256 | )} 257 | {enableAdd && ( 258 | { 260 | event.preventDefault() 261 | onAdd?.(path) 262 | }} 263 | > 264 | 265 | 266 | )} 267 | {enableDelete && ( 268 | { 270 | event.preventDefault() 271 | onDelete?.(path, value) 272 | }} 273 | > 274 | 275 | 276 | )} 277 | 278 | ) 279 | }, 280 | [ 281 | Editor, 282 | serialize, 283 | deserialize, 284 | copied, 285 | copy, 286 | editable, 287 | editing, 288 | enableClipboard, 289 | enableAdd, 290 | enableDelete, 291 | tempValue, 292 | path, 293 | value, 294 | onAdd, 295 | onDelete, 296 | startEditing, 297 | abortEditing, 298 | commitEditing 299 | ]) 300 | 301 | const isEmptyValue = useMemo(() => getValueSize(value) === 0, [value]) 302 | const expandable = !isEmptyValue && !!(PreComponent && PostComponent) 303 | const KeyRenderer = useJsonViewerStore(store => store.keyRenderer) 304 | const downstreamProps: DataItemProps = useMemo(() => ({ 305 | path, 306 | inspect, 307 | setInspect, 308 | value, 309 | prevValue, 310 | nestedIndex 311 | }), [inspect, path, setInspect, value, prevValue, nestedIndex]) 312 | return ( 313 | setHover(path, nestedIndex), 319 | [setHover, path, nestedIndex]) 320 | } 321 | > 322 | ) => { 333 | if (event.isDefaultPrevented()) { 334 | return 335 | } 336 | if (!isEmptyValue) { 337 | setInspect(state => !state) 338 | } 339 | }, [isEmptyValue, setInspect]) 340 | } 341 | > 342 | { 343 | expandable 344 | ? (inspect 345 | ? ( 346 | 353 | ) 354 | : ( 355 | 362 | ) 363 | ) 364 | : null 365 | } 366 | 371 | { 372 | (isRoot && depth === 0 373 | ? rootName !== false 374 | ? (quotesOnKeys ? <>"{rootName}" : <>{rootName}) 375 | : null 376 | : KeyRenderer.when(downstreamProps) 377 | ? 378 | : nestedIndex === undefined && ( 379 | isNumberKey 380 | ? ( 381 | 388 | {key} 389 | 390 | ) 391 | : quotesOnKeys ? <>"{key}" : <>{key} 392 | ) 393 | ) 394 | } 395 | 396 | { 397 | ( 398 | isRoot 399 | ? (rootName !== false && ( 400 | : 404 | )) 405 | : nestedIndex === undefined && ( 406 | : 414 | ) 415 | ) 416 | } 417 | {PreComponent && } 418 | {(isHover && expandable && inspect) && actionIcons} 419 | 420 | { 421 | (editing && editable) 422 | ? (Editor && ( 423 | 430 | )) 431 | : (Component) 432 | ? 433 | : ( 434 | 435 | {`fallback: ${value}`} 436 | 437 | ) 438 | } 439 | {PostComponent && } 440 | {!last && displayComma && ,} 441 | {(isHover && expandable && !inspect) && actionIcons} 442 | {(isHover && !expandable) && actionIcons} 443 | {(!isHover && editing) && actionIcons} 444 | 445 | ) 446 | } 447 | -------------------------------------------------------------------------------- /src/components/DataTypeLabel.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { DataBox } from './mui/DataBox' 4 | 5 | export type DataLabelProps = { 6 | dataType: string 7 | enable?: boolean 8 | } 9 | 10 | export const DataTypeLabel: FC = ({ dataType, enable = true }) => { 11 | if (!enable) return null 12 | 13 | return ( 14 | 23 | {dataType} 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/DataTypes/Boolean.tsx: -------------------------------------------------------------------------------- 1 | import { defineEasyType } from './defineEasyType' 2 | 3 | export const booleanType = defineEasyType({ 4 | is: (value) => typeof value === 'boolean', 5 | type: 'bool', 6 | colorKey: 'base0E', 7 | serialize: value => value.toString(), 8 | deserialize: value => { 9 | if (value === 'true') return true 10 | if (value === 'false') return false 11 | throw new Error('Invalid boolean value') 12 | }, 13 | Renderer: ({ value }) => <>{value ? 'true' : 'false'} 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/DataTypes/Date.tsx: -------------------------------------------------------------------------------- 1 | import { defineEasyType } from './defineEasyType' 2 | 3 | const displayOptions: Intl.DateTimeFormatOptions = { 4 | weekday: 'short', 5 | year: 'numeric', 6 | month: 'short', 7 | day: 'numeric', 8 | hour: '2-digit', 9 | minute: '2-digit' 10 | } 11 | 12 | export const dateType = defineEasyType({ 13 | is: (value) => value instanceof Date, 14 | type: 'date', 15 | colorKey: 'base0D', 16 | Renderer: ({ value }) => <>{value.toLocaleTimeString('en-us', displayOptions)} 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/DataTypes/Function.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Use NoSsr on function value. 3 | * Because in Next.js SSR, the function will be translated to other type 4 | */ 5 | import { Box, NoSsr } from '@mui/material' 6 | import type { FC } from 'react' 7 | 8 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 9 | import type { DataItemProps, DataType } from '../../type' 10 | import { DataTypeLabel } from '../DataTypeLabel' 11 | 12 | const functionBody = (func: Function) => { 13 | const funcString = func.toString() 14 | 15 | let isUsualFunction = true 16 | const parenthesisPos = funcString.indexOf(')') 17 | const arrowPos = funcString.indexOf('=>') 18 | if (arrowPos !== -1 && arrowPos > parenthesisPos) { 19 | isUsualFunction = false 20 | } 21 | if (isUsualFunction) { 22 | return funcString.substring( 23 | funcString.indexOf('{', parenthesisPos) + 1, 24 | funcString.lastIndexOf('}') 25 | ) 26 | } 27 | 28 | return funcString.substring(funcString.indexOf('=>') + 2) 29 | } 30 | 31 | const functionName = (func: Function) => { 32 | const funcString = func.toString() 33 | const isUsualFunction = funcString.indexOf('function') !== -1 34 | if (isUsualFunction) { 35 | return funcString.substring(8, funcString.indexOf('{')).trim() 36 | } 37 | 38 | return funcString.substring(0, funcString.indexOf('=>') + 2).trim() 39 | } 40 | 41 | const lb = '{' 42 | const rb = '}' 43 | 44 | const PreFunctionType: FC> = (props) => { 45 | return ( 46 | 47 | 48 | 55 | {functionName(props.value)} 56 | {' '}{lb} 57 | 58 | 59 | ) 60 | } 61 | 62 | const PostFunctionType: FC> = () => { 63 | return ( 64 | 65 | 66 | {rb} 67 | 68 | 69 | ) 70 | } 71 | 72 | const FunctionType: FC> = (props) => { 73 | const functionColor = useJsonViewerStore(store => store.colorspace.base05) 74 | return ( 75 | 76 | 84 | {props.inspect 85 | ? functionBody(props.value) 86 | : ( 87 | props.setInspect(true)} 91 | sx={{ 92 | '&:hover': { cursor: 'pointer' }, 93 | padding: 0.5 94 | }} 95 | > 96 | … 97 | 98 | )} 99 | 100 | 101 | ) 102 | } 103 | 104 | export const functionType: DataType = { 105 | is: (value) => typeof value === 'function', 106 | Component: FunctionType, 107 | PreComponent: PreFunctionType, 108 | PostComponent: PostFunctionType 109 | } 110 | -------------------------------------------------------------------------------- /src/components/DataTypes/Null.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | 3 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 4 | import { defineEasyType } from './defineEasyType' 5 | 6 | export const nullType = defineEasyType({ 7 | is: (value) => value === null, 8 | type: 'null', 9 | colorKey: 'base08', 10 | displayTypeLabel: false, 11 | Renderer: () => { 12 | const backgroundColor = useJsonViewerStore(store => store.colorspace.base02) 13 | return ( 14 | 23 | NULL 24 | 25 | ) 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /src/components/DataTypes/Number.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | 3 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 4 | import { defineEasyType } from './defineEasyType' 5 | 6 | const isInt = (n: number) => n % 1 === 0 7 | 8 | export const nanType = defineEasyType({ 9 | is: (value) => typeof value === 'number' && isNaN(value), 10 | type: 'NaN', 11 | colorKey: 'base08', 12 | displayTypeLabel: false, 13 | serialize: () => 'NaN', 14 | // allow deserialize the value back to number 15 | deserialize: (value) => parseFloat(value), 16 | Renderer: () => { 17 | const backgroundColor = useJsonViewerStore(store => store.colorspace.base02) 18 | return ( 19 | 28 | NaN 29 | 30 | ) 31 | } 32 | }) 33 | 34 | export const floatType = defineEasyType({ 35 | is: (value) => typeof value === 'number' && !isInt(value) && !isNaN(value), 36 | type: 'float', 37 | colorKey: 'base0B', 38 | serialize: value => value.toString(), 39 | deserialize: value => parseFloat(value), 40 | Renderer: ({ value }) => <>{value} 41 | }) 42 | 43 | export const intType = defineEasyType({ 44 | is: (value) => typeof value === 'number' && isInt(value), 45 | type: 'int', 46 | colorKey: 'base0F', 47 | serialize: value => value.toString(), 48 | // allow deserialize the value to float 49 | deserialize: value => parseFloat(value), 50 | Renderer: ({ value }) => <>{value} 51 | }) 52 | 53 | export const bigIntType = defineEasyType({ 54 | is: (value) => typeof value === 'bigint', 55 | type: 'bigint', 56 | colorKey: 'base0F', 57 | serialize: value => value.toString(), 58 | deserialize: value => BigInt(value.replace(/\D/g, '')), 59 | Renderer: ({ value }) => <>{`${value}n`} 60 | }) 61 | -------------------------------------------------------------------------------- /src/components/DataTypes/Object.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import type { FC } from 'react' 3 | import { useMemo, useState } from 'react' 4 | 5 | import { useTextColor } from '../../hooks/useColor' 6 | import { useIsCycleReference } from '../../hooks/useIsCycleReference' 7 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 8 | import type { DataItemProps, DataType } from '../../type' 9 | import { getValueSize, segmentArray } from '../../utils' 10 | import { DataKeyPair } from '../DataKeyPair' 11 | import { CircularArrowsIcon } from '../Icons' 12 | import { DataBox } from '../mui/DataBox' 13 | 14 | const objectLb = '{' 15 | const arrayLb = '[' 16 | const objectRb = '}' 17 | const arrayRb = ']' 18 | 19 | function inspectMetadata (value: object) { 20 | const length = getValueSize(value) 21 | 22 | let name = '' 23 | if (value instanceof Map || value instanceof Set) { 24 | name = value[Symbol.toStringTag] 25 | } 26 | if (Object.prototype.hasOwnProperty.call(value, Symbol.toStringTag)) { 27 | name = (value as any)[Symbol.toStringTag] 28 | } 29 | const itemsPluralized = length === 1 ? 'Item' : 'Items' 30 | return `${length} ${itemsPluralized}${name ? ` (${name})` : ''}` 31 | } 32 | 33 | const PreObjectType: FC> = (props) => { 34 | const metadataColor = useJsonViewerStore(store => store.colorspace.base04) 35 | const textColor = useTextColor() 36 | const isArrayLike = useMemo(() => Array.isArray(props.value) || (props.value instanceof Set), [props.value]) 37 | const isEmptyValue = useMemo(() => getValueSize(props.value) === 0, [props.value]) 38 | const sizeOfValue = useMemo(() => inspectMetadata(props.value), [props.value]) 39 | const displaySize = useJsonViewerStore(store => store.displaySize) 40 | const shouldDisplaySize = useMemo(() => typeof displaySize === 'function' ? displaySize(props.path, props.value) : displaySize, [displaySize, props.path, props.value]) 41 | const isTrap = useIsCycleReference(props.path, props.value) 42 | return ( 43 | 50 | {isArrayLike ? arrayLb : objectLb} 51 | {shouldDisplaySize && props.inspect && !isEmptyValue && ( 52 | 61 | {sizeOfValue} 62 | 63 | )} 64 | 65 | {isTrap && !props.inspect && ( 66 | <> 67 | 74 | 80 | {isTrap} 81 | 82 | 83 | )} 84 | 85 | ) 86 | } 87 | 88 | const PostObjectType: FC> = (props) => { 89 | const metadataColor = useJsonViewerStore(store => store.colorspace.base04) 90 | const textColor = useTextColor() 91 | const isArrayLike = useMemo(() => Array.isArray(props.value) || (props.value instanceof Set), [props.value]) 92 | const isEmptyValue = useMemo(() => getValueSize(props.value) === 0, [props.value]) 93 | const sizeOfValue = useMemo(() => inspectMetadata(props.value), [props.value]) 94 | const displaySize = useJsonViewerStore(store => store.displaySize) 95 | const shouldDisplaySize = useMemo(() => typeof displaySize === 'function' ? displaySize(props.path, props.value) : displaySize, [displaySize, props.path, props.value]) 96 | 97 | return ( 98 | 108 | {isArrayLike ? arrayRb : objectRb} 109 | {shouldDisplaySize && (isEmptyValue || !props.inspect) 110 | ? ( 111 | 120 | {sizeOfValue} 121 | 122 | ) 123 | : null} 124 | 125 | ) 126 | } 127 | 128 | function getIterator (value: any): value is Iterable { 129 | return typeof value?.[Symbol.iterator] === 'function' 130 | } 131 | 132 | const ObjectType: FC> = (props) => { 133 | const keyColor = useTextColor() 134 | const borderColor = useJsonViewerStore(store => store.colorspace.base02) 135 | const groupArraysAfterLength = useJsonViewerStore(store => store.groupArraysAfterLength) 136 | const isTrap = useIsCycleReference(props.path, props.value) 137 | const [displayLength, setDisplayLength] = useState(useJsonViewerStore(store => store.maxDisplayLength)) 138 | const objectSortKeys = useJsonViewerStore(store => store.objectSortKeys) 139 | const elements = useMemo(() => { 140 | if (!props.inspect) { 141 | return null 142 | } 143 | const value: unknown[] | object = props.value 144 | const iterator = getIterator(value) 145 | // Array also has iterator, we skip it and treat it as an array as normal. 146 | if (iterator && !Array.isArray(value)) { 147 | const elements = [] 148 | if (value instanceof Map) { 149 | const lastIndex = value.size - 1 150 | let index = 0 151 | value.forEach((value, k) => { 152 | // fixme: key might be a object, array, or any value for the `Map` 153 | const key = k.toString() 154 | const path = [...props.path, key] 155 | elements.push( 156 | 164 | ) 165 | index++ 166 | }) 167 | } else { 168 | // iterate with iterator func 169 | const iterator = value[Symbol.iterator]() 170 | let result = iterator.next() 171 | let count = 0 172 | while (true) { 173 | const nextResult = iterator.next() 174 | elements.push( 175 | 183 | ) 184 | 185 | if (nextResult.done) { 186 | break 187 | } 188 | 189 | count++ 190 | result = nextResult 191 | } 192 | } 193 | return elements 194 | } 195 | if (Array.isArray(value)) { 196 | const lastIndex = value.length - 1 197 | // unknown[] 198 | if (value.length <= groupArraysAfterLength) { 199 | const elements = value.slice(0, displayLength).map((value, _index) => { 200 | const index = props.nestedIndex ? (props.nestedIndex * groupArraysAfterLength) + _index : _index 201 | const path = [...props.path, index] 202 | return ( 203 | 210 | ) 211 | }) 212 | if (value.length > displayLength) { 213 | const rest = value.length - displayLength 214 | elements.push( 215 | setDisplayLength(length => length * 2)} 226 | > 227 | hidden {rest} items… 228 | 229 | ) 230 | } 231 | return elements 232 | } 233 | 234 | const elements: unknown[][] = segmentArray(value, groupArraysAfterLength) 235 | const prevElements = Array.isArray(props.prevValue) ? segmentArray(props.prevValue, groupArraysAfterLength) : undefined 236 | 237 | const elementsLastIndex = elements.length - 1 238 | return elements.map((list, index) => { 239 | return ( 240 | 248 | ) 249 | }) 250 | } 251 | // object 252 | let entries: [key: string, value: unknown][] = Object.entries(value) 253 | if (objectSortKeys) { 254 | entries = objectSortKeys === true 255 | ? entries.sort(([a], [b]) => a.localeCompare(b)) 256 | : entries.sort(([a], [b]) => objectSortKeys(a, b)) 257 | } 258 | const lastIndex = entries.length - 1 259 | const elements = entries.slice(0, displayLength).map(([key, value], index) => { 260 | const path = [...props.path, key] 261 | return ( 262 | 269 | ) 270 | }) 271 | if (entries.length > displayLength) { 272 | const rest = entries.length - displayLength 273 | elements.push( 274 | setDisplayLength(length => length * 2)} 285 | > 286 | hidden {rest} items… 287 | 288 | ) 289 | } 290 | return elements 291 | }, [ 292 | props.inspect, 293 | props.value, 294 | props.prevValue, 295 | props.path, 296 | props.nestedIndex, 297 | groupArraysAfterLength, 298 | displayLength, 299 | keyColor, 300 | objectSortKeys 301 | ]) 302 | const marginLeft = props.inspect ? 0.6 : 0 303 | const width = useJsonViewerStore(store => store.indentWidth) 304 | const indentWidth = props.inspect ? width - marginLeft : width 305 | const isEmptyValue = useMemo(() => getValueSize(props.value) === 0, [props.value]) 306 | if (isEmptyValue) { 307 | return null 308 | } 309 | return ( 310 | 320 | { 321 | props.inspect 322 | ? elements 323 | : !isTrap && ( 324 | props.setInspect(true)} 328 | sx={{ 329 | '&:hover': { cursor: 'pointer' }, 330 | padding: 0.5, 331 | userSelect: 'none' 332 | }} 333 | > 334 | … 335 | 336 | ) 337 | } 338 | 339 | ) 340 | } 341 | 342 | export const objectType: DataType = { 343 | is: (value) => typeof value === 'object', 344 | Component: ObjectType, 345 | PreComponent: PreObjectType, 346 | PostComponent: PostObjectType 347 | } 348 | -------------------------------------------------------------------------------- /src/components/DataTypes/String.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { useState } from 'react' 3 | 4 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 5 | import { defineEasyType } from './defineEasyType' 6 | 7 | export const stringType = defineEasyType({ 8 | is: (value) => typeof value === 'string', 9 | type: 'string', 10 | colorKey: 'base09', 11 | serialize: value => value, 12 | deserialize: value => value, 13 | Renderer: (props) => { 14 | const [showRest, setShowRest] = useState(false) 15 | const collapseStringsAfterLength = useJsonViewerStore(store => store.collapseStringsAfterLength) 16 | const value = showRest 17 | ? props.value 18 | : props.value.slice(0, collapseStringsAfterLength) 19 | const hasRest = props.value.length > collapseStringsAfterLength 20 | return ( 21 | { 28 | if (window.getSelection()?.type === 'Range') { 29 | return 30 | } 31 | 32 | if (hasRest) { 33 | setShowRest(value => !value) 34 | } 35 | }} 36 | > 37 | " 38 | {value} 39 | {hasRest && !showRest && ()} 40 | " 41 | 42 | ) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/components/DataTypes/Undefined.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | 3 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 4 | import { defineEasyType } from './defineEasyType' 5 | 6 | export const undefinedType = defineEasyType({ 7 | is: (value) => value === undefined, 8 | type: 'undefined', 9 | colorKey: 'base05', 10 | displayTypeLabel: false, 11 | Renderer: () => { 12 | const backgroundColor = useJsonViewerStore(store => store.colorspace.base02) 13 | return ( 14 | 22 | undefined 23 | 24 | ) 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/DataTypes/defineEasyType.tsx: -------------------------------------------------------------------------------- 1 | import { InputBase } from '@mui/material' 2 | import type { ChangeEventHandler, ComponentType, FC, KeyboardEvent } from 'react' 3 | import { memo, useCallback } from 'react' 4 | 5 | import { useJsonViewerStore } from '../../stores/JsonViewerStore' 6 | import type { Colorspace } from '../../theme/base16' 7 | import type { DataItemProps, DataType, EditorProps } from '../../type' 8 | import { DataTypeLabel } from '../DataTypeLabel' 9 | import { DataBox } from '../mui/DataBox' 10 | 11 | export type EasyTypeConfig = Pick, 'is' | 'serialize' | 'deserialize'> & { 12 | type: string 13 | colorKey: keyof Colorspace 14 | displayTypeLabel?: boolean 15 | Renderer: ComponentType> 16 | } 17 | /** 18 | * Enhanced version of `defineDataType` that creates a `DataType` with editor and a optional type label. 19 | * It will take care of the color and all the necessary props. 20 | * 21 | * *All of the built-in data types are defined with this function.* 22 | * 23 | * @param config.type The type name. 24 | * @param config.colorKey The color key in the colorspace. ('base00' - 'base0F') 25 | * @param config.displayTypeLabel Whether to display the type label. 26 | * @param config.Renderer The component to render the value. 27 | */ 28 | export function defineEasyType ({ 29 | is, 30 | serialize, 31 | deserialize, 32 | type, 33 | colorKey, 34 | displayTypeLabel = true, 35 | Renderer 36 | }: EasyTypeConfig): DataType { 37 | const Render = memo(Renderer) 38 | const EasyType: FC> = (props) => { 39 | const storeDisplayDataTypes = useJsonViewerStore(store => store.displayDataTypes) 40 | const color = useJsonViewerStore(store => store.colorspace[colorKey]) 41 | const onSelect = useJsonViewerStore(store => store.onSelect) 42 | 43 | return ( 44 | onSelect?.(props.path, props.value)} sx={{ color }}> 45 | {(displayTypeLabel && storeDisplayDataTypes) && } 46 | 47 | 54 | 55 | 56 | ) 57 | } 58 | EasyType.displayName = `easy-${type}-type` 59 | 60 | if (!serialize || !deserialize) { 61 | return { 62 | is, 63 | Component: EasyType 64 | } 65 | } 66 | 67 | const EasyTypeEditor: FC> = ({ value, setValue, abortEditing, commitEditing }) => { 68 | const color = useJsonViewerStore(store => store.colorspace[colorKey]) 69 | 70 | const handleKeyDown = useCallback((event: KeyboardEvent) => { 71 | if (event.key === 'Enter') { 72 | event.preventDefault() 73 | commitEditing(value) 74 | } 75 | 76 | if (event.key === 'Escape') { 77 | event.preventDefault() 78 | abortEditing() 79 | } 80 | }, [abortEditing, commitEditing, value]) 81 | 82 | const handleChange = useCallback>((event) => { 83 | setValue(event.target.value) 84 | }, [setValue]) 85 | 86 | return ( 87 | 105 | ) 106 | } 107 | EasyTypeEditor.displayName = `easy-${type}-type-editor` 108 | 109 | return { 110 | is, 111 | serialize, 112 | deserialize, 113 | Component: EasyType, 114 | Editor: EasyTypeEditor 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/DataTypes/index.ts: -------------------------------------------------------------------------------- 1 | export { booleanType } from './Boolean' 2 | export { dateType } from './Date' 3 | export { defineEasyType, type EasyTypeConfig } from './defineEasyType' 4 | export { functionType } from './Function' 5 | export { nullType } from './Null' 6 | export { bigIntType, floatType, intType, nanType } from './Number' 7 | export { objectType } from './Object' 8 | export { stringType } from './String' 9 | export { undefinedType } from './Undefined' 10 | -------------------------------------------------------------------------------- /src/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import type { SvgIconProps } from '@mui/material' 2 | import { SvgIcon } from '@mui/material' 3 | import type { FC } from 'react' 4 | 5 | const BaseIcon: FC = ({ d, ...props }) => { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | const AddBox = 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2m0 16H5V5h14zm-8-2h2v-4h4v-2h-4V7h-2v4H7v2h4z' 14 | const Check = 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' 15 | const ChevronRight = 'M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' 16 | const CircularArrows = 'M 12 2 C 10.615 1.998 9.214625 2.2867656 7.890625 2.8847656 L 8.9003906 4.6328125 C 9.9043906 4.2098125 10.957 3.998 12 4 C 15.080783 4 17.738521 5.7633175 19.074219 8.3222656 L 17.125 9 L 21.25 11 L 22.875 7 L 20.998047 7.6523438 C 19.377701 4.3110398 15.95585 2 12 2 z M 6.5097656 4.4882812 L 2.2324219 5.0820312 L 3.734375 6.3808594 C 1.6515335 9.4550558 1.3615962 13.574578 3.3398438 17 C 4.0308437 18.201 4.9801562 19.268234 6.1601562 20.115234 L 7.1699219 18.367188 C 6.3019219 17.710187 5.5922656 16.904 5.0722656 16 C 3.5320014 13.332354 3.729203 10.148679 5.2773438 7.7128906 L 6.8398438 9.0625 L 6.5097656 4.4882812 z M 19.929688 13 C 19.794687 14.08 19.450734 15.098 18.927734 16 C 17.386985 18.668487 14.531361 20.090637 11.646484 19.966797 L 12.035156 17.9375 L 8.2402344 20.511719 L 10.892578 23.917969 L 11.265625 21.966797 C 14.968963 22.233766 18.681899 20.426323 20.660156 17 C 21.355156 15.801 21.805219 14.445 21.949219 13 L 19.929688 13 z' 17 | const Close = 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z' 18 | const ContentCopy = 'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z' 19 | const Edit = 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z' 20 | const ExpandMore = 'M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z' 21 | const Delete = 'M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z' 22 | 23 | export const AddBoxIcon: FC = (props) => { 24 | return 25 | } 26 | 27 | export const CheckIcon: FC = (props) => { 28 | return 29 | } 30 | 31 | export const ChevronRightIcon: FC = (props) => { 32 | return 33 | } 34 | 35 | export const CircularArrowsIcon: FC = (props) => { 36 | return 37 | } 38 | 39 | export const CloseIcon: FC = (props) => { 40 | return 41 | } 42 | 43 | export const ContentCopyIcon: FC = (props) => { 44 | return 45 | } 46 | 47 | export const EditIcon: FC = (props) => { 48 | return 49 | } 50 | 51 | export const ExpandMoreIcon: FC = (props) => { 52 | return 53 | } 54 | 55 | export const DeleteIcon: FC = (props) => { 56 | return 57 | } 58 | -------------------------------------------------------------------------------- /src/components/mui/DataBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import type { ComponentProps, ElementType, FC } from 'react' 3 | 4 | type DataBoxProps = ComponentProps & { 5 | component?: ElementType 6 | } 7 | 8 | export const DataBox: FC = props => ( 9 | 17 | ) 18 | -------------------------------------------------------------------------------- /src/hooks/useColor.ts: -------------------------------------------------------------------------------- 1 | import { useJsonViewerStore } from '../stores/JsonViewerStore' 2 | 3 | export const useTextColor = () => { 4 | return useJsonViewerStore(store => store.colorspace.base07) 5 | } 6 | -------------------------------------------------------------------------------- /src/hooks/useCopyToClipboard.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react' 2 | 3 | import { useJsonViewerStore } from '../stores/JsonViewerStore' 4 | import type { JsonViewerOnCopy } from '../type' 5 | import { copyString, safeStringify } from '../utils' 6 | 7 | /** 8 | * useClipboard hook accepts one argument options in which copied status timeout duration is defined (defaults to 2000). Hook returns object with properties: 9 | * - copy – function to copy value to clipboard 10 | * - copied – value that indicates that copy handler was called less than options.timeout ms ago 11 | * - reset – function to clear timeout and reset copied to false 12 | */ 13 | export function useClipboard ({ timeout = 2000 } = {}) { 14 | const [copied, setCopied] = useState(false) 15 | const copyTimeout = useRef(null) 16 | 17 | const handleCopyResult = useCallback((value: boolean) => { 18 | const current = copyTimeout.current 19 | if (current) { 20 | window.clearTimeout(current) 21 | } 22 | copyTimeout.current = window.setTimeout(() => setCopied(false), timeout) 23 | setCopied(value) 24 | }, [timeout]) 25 | const onCopy = useJsonViewerStore(store => store.onCopy) 26 | 27 | const copy = useCallback(async (path, value: unknown) => { 28 | if (typeof onCopy === 'function') { 29 | try { 30 | await onCopy(path, value, copyString) 31 | handleCopyResult(true) 32 | } catch (error) { 33 | console.error( 34 | `error when copy ${path.length === 0 35 | ? 'src' 36 | : `src[${path.join( 37 | '.')}` 38 | }]`, error) 39 | } 40 | } else { 41 | try { 42 | const valueToCopy = safeStringify( 43 | typeof value === 'function' ? value.toString() : value, 44 | ' ' 45 | ) 46 | await copyString(valueToCopy) 47 | handleCopyResult(true) 48 | } catch (error) { 49 | console.error( 50 | `error when copy ${path.length === 0 51 | ? 'src' 52 | : `src[${path.join( 53 | '.')}` 54 | }]`, error) 55 | } 56 | } 57 | }, [handleCopyResult, onCopy]) 58 | 59 | const reset = useCallback(() => { 60 | setCopied(false) 61 | if (copyTimeout.current) { 62 | clearTimeout(copyTimeout.current) 63 | } 64 | }, []) 65 | 66 | return { copy, reset, copied } 67 | } 68 | -------------------------------------------------------------------------------- /src/hooks/useInspect.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Dispatch, 3 | SetStateAction 4 | } from 'react' 5 | import { 6 | useCallback, 7 | useEffect, 8 | useState 9 | } from 'react' 10 | 11 | import { useJsonViewerStore } from '../stores/JsonViewerStore' 12 | import { useIsCycleReference } from './useIsCycleReference' 13 | 14 | export function useInspect (path: (string | number)[], value: any, nestedIndex?: number) { 15 | const depth = path.length 16 | const isTrap = useIsCycleReference(path, value) 17 | const getInspectCache = useJsonViewerStore(store => store.getInspectCache) 18 | const setInspectCache = useJsonViewerStore(store => store.setInspectCache) 19 | const defaultInspectDepth = useJsonViewerStore(store => store.defaultInspectDepth) 20 | const defaultInspectControl = useJsonViewerStore(store => store.defaultInspectControl) 21 | useEffect(() => { 22 | const inspect = getInspectCache(path, nestedIndex) 23 | if (inspect !== undefined) { 24 | return 25 | } 26 | 27 | // item with nestedIndex should not be inspected 28 | if (nestedIndex !== undefined) { 29 | setInspectCache(path, false, nestedIndex) 30 | return 31 | } 32 | 33 | // do not inspect when it is a cycle reference, otherwise there will have a loop 34 | const shouldInspect = isTrap 35 | ? false 36 | : typeof defaultInspectControl === 'function' 37 | ? defaultInspectControl(path, value) 38 | : depth < defaultInspectDepth 39 | setInspectCache(path, shouldInspect) 40 | }, [defaultInspectDepth, defaultInspectControl, depth, getInspectCache, isTrap, nestedIndex, path, value, setInspectCache]) 41 | 42 | const [inspect, set] = useState(() => { 43 | const shouldInspect = getInspectCache(path, nestedIndex) 44 | if (shouldInspect !== undefined) { 45 | return shouldInspect 46 | } 47 | if (nestedIndex !== undefined) { 48 | return false 49 | } 50 | return isTrap 51 | ? false 52 | : typeof defaultInspectControl === 'function' 53 | ? defaultInspectControl(path, value) 54 | : depth < defaultInspectDepth 55 | }) 56 | const setInspect = useCallback>>((apply) => { 57 | set((oldState) => { 58 | const newState = typeof apply === 'boolean' ? apply : apply(oldState) 59 | setInspectCache(path, newState, nestedIndex) 60 | return newState 61 | }) 62 | }, [nestedIndex, path, setInspectCache]) 63 | return [inspect, setInspect] as const 64 | } 65 | -------------------------------------------------------------------------------- /src/hooks/useIsCycleReference.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | 3 | import { useJsonViewerStore } from '../stores/JsonViewerStore' 4 | import { isCycleReference } from '../utils' 5 | 6 | export function useIsCycleReference (path: (string | number)[], value: any) { 7 | const rootValue = useJsonViewerStore(store => store.value) 8 | return useMemo( 9 | () => isCycleReference(rootValue, path, value), 10 | [path, value, rootValue]) 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useThemeDetector.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const query = '(prefers-color-scheme: dark)' 4 | export function useThemeDetector () { 5 | const [isDark, setIsDark] = useState(false) 6 | 7 | useEffect(() => { 8 | const listener = (e: MediaQueryListEvent) => setIsDark(e.matches) 9 | setIsDark(window.matchMedia(query).matches) 10 | const queryMedia = window.matchMedia(query) 11 | queryMedia.addEventListener('change', listener) 12 | return () => queryMedia.removeEventListener('change', listener) 13 | }, []) 14 | return isDark 15 | } 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createTheme, Paper, 3 | ThemeProvider 4 | } from '@mui/material' 5 | import clsx from 'clsx' 6 | import type { FC, ReactElement } from 'react' 7 | import { useCallback, useContext, useEffect, useMemo, useRef } from 'react' 8 | 9 | import { DataKeyPair } from './components/DataKeyPair' 10 | import { useThemeDetector } from './hooks/useThemeDetector' 11 | import { 12 | createJsonViewerStore, 13 | JsonViewerStoreContext, 14 | useJsonViewerStore 15 | } from './stores/JsonViewerStore' 16 | import { 17 | createTypeRegistryStore, 18 | predefinedTypes, 19 | TypeRegistryStoreContext, 20 | useTypeRegistryStore 21 | } from './stores/typeRegistry' 22 | import { darkColorspace, lightColorspace } from './theme/base16' 23 | import type { JsonViewerProps } from './type' 24 | 25 | /** 26 | * @internal 27 | */ 28 | function useSetIfNotUndefinedEffect ( 29 | key: Key, 30 | value: JsonViewerProps[Key] | undefined 31 | ) { 32 | const { setState } = useContext(JsonViewerStoreContext) 33 | useEffect(() => { 34 | if (value !== undefined) { 35 | setState({ 36 | [key]: value 37 | }) 38 | } 39 | }, [key, value, setState]) 40 | } 41 | 42 | /** 43 | * @internal 44 | */ 45 | const JsonViewerInner: FC = (props) => { 46 | const { setState } = useContext(JsonViewerStoreContext) 47 | useEffect(() => { 48 | setState((state) => ({ 49 | prevValue: state.value, 50 | value: props.value 51 | })) 52 | }, [props.value, setState]) 53 | useSetIfNotUndefinedEffect('rootName', props.rootName) 54 | useSetIfNotUndefinedEffect('indentWidth', props.indentWidth) 55 | useSetIfNotUndefinedEffect('keyRenderer', props.keyRenderer) 56 | useSetIfNotUndefinedEffect('enableAdd', props.enableAdd) 57 | useSetIfNotUndefinedEffect('enableDelete', props.enableDelete) 58 | useSetIfNotUndefinedEffect('enableClipboard', props.enableClipboard) 59 | useSetIfNotUndefinedEffect('editable', props.editable) 60 | useSetIfNotUndefinedEffect('onChange', props.onChange) 61 | useSetIfNotUndefinedEffect('onCopy', props.onCopy) 62 | useSetIfNotUndefinedEffect('onSelect', props.onSelect) 63 | useSetIfNotUndefinedEffect('onAdd', props.onAdd) 64 | useSetIfNotUndefinedEffect('onDelete', props.onDelete) 65 | useSetIfNotUndefinedEffect('maxDisplayLength', props.maxDisplayLength) 66 | useSetIfNotUndefinedEffect('groupArraysAfterLength', props.groupArraysAfterLength) 67 | useSetIfNotUndefinedEffect('quotesOnKeys', props.quotesOnKeys) 68 | useSetIfNotUndefinedEffect('displayDataTypes', props.displayDataTypes) 69 | useSetIfNotUndefinedEffect('displaySize', props.displaySize) 70 | useSetIfNotUndefinedEffect('displayComma', props.displayComma) 71 | useSetIfNotUndefinedEffect('highlightUpdates', props.highlightUpdates) 72 | useEffect(() => { 73 | if (props.theme === 'light') { 74 | setState({ 75 | colorspace: lightColorspace 76 | }) 77 | } else if (props.theme === 'dark') { 78 | setState({ 79 | colorspace: darkColorspace 80 | }) 81 | } else if (typeof props.theme === 'object') { 82 | setState({ 83 | colorspace: props.theme 84 | }) 85 | } 86 | }, [setState, props.theme]) 87 | const themeCls = useMemo(() => { 88 | if (typeof props.theme === 'object') return 'json-viewer-theme-custom' 89 | return props.theme === 'dark' ? 'json-viewer-theme-dark' : 'json-viewer-theme-light' 90 | }, [props.theme]) 91 | const onceRef = useRef(true) 92 | const registerTypes = useTypeRegistryStore(store => store.registerTypes) 93 | if (onceRef.current) { 94 | const allTypes = props.valueTypes 95 | ? [...predefinedTypes, ...props.valueTypes] 96 | : [...predefinedTypes] 97 | registerTypes(allTypes) 98 | onceRef.current = false 99 | } 100 | useEffect(() => { 101 | const allTypes = props.valueTypes 102 | ? [...predefinedTypes, ...props.valueTypes] 103 | : [...predefinedTypes] 104 | registerTypes(allTypes) 105 | }, [props.valueTypes, registerTypes]) 106 | 107 | const value = useJsonViewerStore(store => store.value) 108 | const prevValue = useJsonViewerStore(store => store.prevValue) 109 | const emptyPath = useMemo(() => [], []) 110 | const setHover = useJsonViewerStore(store => store.setHover) 111 | const onMouseLeave = useCallback(() => setHover(null), [setHover]) 112 | return ( 113 | 125 | 131 | 132 | ) 133 | } 134 | 135 | export const JsonViewer = function JsonViewer (props: JsonViewerProps): ReactElement { 136 | const isAutoDarkTheme = useThemeDetector() 137 | const themeType = useMemo(() => props.theme === 'auto' 138 | ? (isAutoDarkTheme ? 'dark' : 'light') 139 | : props.theme ?? 'light', [isAutoDarkTheme, props.theme]) 140 | const theme = useMemo(() => { 141 | const backgroundColor = typeof themeType === 'object' 142 | ? themeType.base00 143 | : themeType === 'dark' 144 | ? darkColorspace.base00 145 | : lightColorspace.base00 146 | const foregroundColor = typeof themeType === 'object' 147 | ? themeType.base07 148 | : themeType === 'dark' 149 | ? darkColorspace.base07 150 | : lightColorspace.base07 151 | return createTheme({ 152 | components: { 153 | MuiPaper: { 154 | styleOverrides: { 155 | root: { 156 | backgroundColor, 157 | color: foregroundColor 158 | } 159 | } 160 | } 161 | }, 162 | palette: { 163 | mode: themeType === 'dark' ? 'dark' : 'light', 164 | background: { 165 | default: backgroundColor 166 | } 167 | } 168 | }) 169 | }, [themeType]) 170 | const mixedProps = { ...props, theme: themeType } 171 | 172 | // eslint-disable-next-line react-hooks/exhaustive-deps 173 | const jsonViewerStore = useMemo(() => createJsonViewerStore(props), []) 174 | const typeRegistryStore = useMemo(() => createTypeRegistryStore(), []) 175 | 176 | return ( 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | ) 185 | } 186 | 187 | export * from './components/DataTypes' 188 | export * from './theme/base16' 189 | export * from './type' 190 | export type { PathValueCustomGetter } from './utils' 191 | export { 192 | applyValue, 193 | defineDataType, 194 | deleteValue, 195 | getPathValue, 196 | isCycleReference, 197 | pathValueDefaultGetter, 198 | safeStringify 199 | } from './utils' 200 | -------------------------------------------------------------------------------- /src/stores/JsonViewerStore.ts: -------------------------------------------------------------------------------- 1 | import type { SetStateAction } from 'react' 2 | import { createContext, useContext } from 'react' 3 | import type { StoreApi } from 'zustand' 4 | import { create, useStore } from 'zustand' 5 | 6 | import type { 7 | JsonViewerOnChange, 8 | JsonViewerOnCopy, 9 | JsonViewerOnSelect, 10 | JsonViewerProps, 11 | Path 12 | } from '..' 13 | import type { Colorspace } from '../theme/base16' 14 | import { lightColorspace } from '../theme/base16' 15 | import type { JsonViewerKeyRenderer, JsonViewerOnAdd, JsonViewerOnDelete } from '../type' 16 | 17 | const DefaultKeyRenderer: JsonViewerKeyRenderer = () => null 18 | DefaultKeyRenderer.when = () => false 19 | 20 | export type JsonViewerState = { 21 | rootName: false | string 22 | indentWidth: number 23 | keyRenderer: JsonViewerKeyRenderer 24 | enableAdd: boolean | ((path: Path, currentValue: U) => boolean) 25 | enableDelete: boolean | ((path: Path, currentValue: U) => boolean) 26 | enableClipboard: boolean 27 | editable: boolean | ((path: Path, currentValue: U) => boolean) 28 | onChange: JsonViewerOnChange 29 | onCopy: JsonViewerOnCopy | undefined 30 | onSelect: JsonViewerOnSelect | undefined 31 | onAdd: JsonViewerOnAdd | undefined 32 | onDelete: JsonViewerOnDelete | undefined 33 | defaultInspectDepth: number 34 | defaultInspectControl?: (path: Path, value: unknown) => boolean 35 | maxDisplayLength: number 36 | groupArraysAfterLength: number 37 | collapseStringsAfterLength: number 38 | objectSortKeys: boolean | ((a: string, b: string) => number) 39 | quotesOnKeys: boolean 40 | displayDataTypes: boolean 41 | displaySize: boolean | ((path: Path, value: unknown) => boolean) 42 | displayComma: boolean 43 | highlightUpdates: boolean 44 | 45 | inspectCache: Record 46 | hoverPath: { path: Path; nestedIndex?: number } | null 47 | colorspace: Colorspace 48 | value: T 49 | prevValue: T | undefined 50 | 51 | getInspectCache: (path: Path, nestedIndex?: number) => boolean 52 | setInspectCache: (path: Path, action: SetStateAction, nestedIndex?: number) => void 53 | setHover: (path: Path | null, nestedIndex?: number) => void 54 | } 55 | 56 | export const createJsonViewerStore = (props: JsonViewerProps) => { 57 | return create()((set, get) => ({ 58 | // provided by user 59 | rootName: props.rootName ?? 'root', 60 | indentWidth: props.indentWidth ?? 3, 61 | keyRenderer: props.keyRenderer ?? DefaultKeyRenderer, 62 | enableAdd: props.enableAdd ?? false, 63 | enableDelete: props.enableDelete ?? false, 64 | enableClipboard: props.enableClipboard ?? true, 65 | editable: props.editable ?? false, 66 | onChange: props.onChange ?? (() => {}), 67 | onCopy: props.onCopy ?? undefined, 68 | onSelect: props.onSelect ?? undefined, 69 | onAdd: props.onAdd ?? undefined, 70 | onDelete: props.onDelete ?? undefined, 71 | defaultInspectDepth: props.defaultInspectDepth ?? 5, 72 | defaultInspectControl: props.defaultInspectControl ?? undefined, 73 | maxDisplayLength: props.maxDisplayLength ?? 30, 74 | groupArraysAfterLength: props.groupArraysAfterLength ?? 100, 75 | collapseStringsAfterLength: 76 | (props.collapseStringsAfterLength === false) 77 | ? Number.MAX_VALUE 78 | : props.collapseStringsAfterLength ?? 50, 79 | objectSortKeys: props.objectSortKeys ?? false, 80 | quotesOnKeys: props.quotesOnKeys ?? true, 81 | displayDataTypes: props.displayDataTypes ?? true, 82 | displaySize: props.displaySize ?? true, 83 | displayComma: props.displayComma ?? false, 84 | highlightUpdates: props.highlightUpdates ?? false, 85 | 86 | // internal state 87 | inspectCache: {}, 88 | hoverPath: null, 89 | colorspace: lightColorspace, 90 | value: props.value, 91 | prevValue: undefined, 92 | 93 | getInspectCache: (path, nestedIndex) => { 94 | const target = nestedIndex !== undefined 95 | ? path.join('.') + `[${nestedIndex}]nt` 96 | : path.join('.') 97 | return get().inspectCache[target] 98 | }, 99 | setInspectCache: (path, action, nestedIndex) => { 100 | const target = nestedIndex !== undefined 101 | ? path.join('.') + `[${nestedIndex}]nt` 102 | : path.join('.') 103 | set(state => ({ 104 | inspectCache: { 105 | ...state.inspectCache, 106 | [target]: typeof action === 'function' 107 | ? action( 108 | state.inspectCache[target]) 109 | : action 110 | } 111 | })) 112 | }, 113 | setHover: (path, nestedIndex) => { 114 | set({ 115 | hoverPath: path 116 | ? ({ path, nestedIndex }) 117 | : null 118 | }) 119 | } 120 | })) 121 | } 122 | 123 | // @ts-expect-error we intentionally want to pass undefined to the context 124 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24509#issuecomment-382213106 125 | export const JsonViewerStoreContext = createContext>(undefined) 126 | 127 | export const JsonViewerProvider = JsonViewerStoreContext.Provider 128 | 129 | export const useJsonViewerStore = (selector: (state: JsonViewerState) => U, equalityFn?: (a: U, b: U) => boolean) => { 130 | const store = useContext(JsonViewerStoreContext) 131 | return useStore(store, selector, equalityFn) 132 | } 133 | -------------------------------------------------------------------------------- /src/stores/typeRegistry.tsx: -------------------------------------------------------------------------------- 1 | import type { SetStateAction } from 'react' 2 | import { createContext, memo, useContext, useMemo } from 'react' 3 | import type { StoreApi } from 'zustand' 4 | import { createStore, useStore } from 'zustand' 5 | 6 | import { 7 | bigIntType, 8 | booleanType, 9 | dateType, 10 | floatType, 11 | functionType, 12 | intType, 13 | nanType, 14 | nullType, 15 | objectType, 16 | stringType, 17 | undefinedType 18 | } from '../components/DataTypes' 19 | import type { DataItemProps, DataType, Path } from '../type' 20 | 21 | function memorizeDataType (dataType: DataType): DataType { 22 | function compare (prevProps: Readonly>, nextProps: Readonly>) { 23 | return ( 24 | Object.is(prevProps.value, nextProps.value) && 25 | prevProps.inspect && nextProps.inspect && 26 | prevProps.path?.join('.') === nextProps.path?.join('.') 27 | ) 28 | } 29 | dataType.Component = memo(dataType.Component, compare) 30 | if (dataType.Editor) { 31 | dataType.Editor = memo(dataType.Editor, function compare (prevProps, nextProps) { 32 | return Object.is(prevProps.value, nextProps.value) 33 | }) 34 | } 35 | if (dataType.PreComponent) { 36 | dataType.PreComponent = memo(dataType.PreComponent, compare) 37 | } 38 | if (dataType.PostComponent) { 39 | dataType.PostComponent = memo(dataType.PostComponent, compare) 40 | } 41 | return dataType 42 | } 43 | 44 | export const predefinedTypes :DataType[] = [ 45 | memorizeDataType(booleanType), 46 | memorizeDataType(dateType), 47 | memorizeDataType(nullType), 48 | memorizeDataType(undefinedType), 49 | memorizeDataType(stringType), 50 | memorizeDataType(functionType), 51 | memorizeDataType(nanType), 52 | memorizeDataType(intType), 53 | memorizeDataType(floatType), 54 | memorizeDataType(bigIntType) 55 | ] 56 | 57 | type TypeRegistryState = { 58 | registry: DataType[] 59 | 60 | registerTypes: (setState: SetStateAction[]>) => void 61 | } 62 | 63 | export const createTypeRegistryStore = () => { 64 | return createStore()((set) => ({ 65 | registry: predefinedTypes, 66 | 67 | registerTypes: (setState) => { 68 | set(state => ({ 69 | registry: 70 | typeof setState === 'function' 71 | ? setState(state.registry) 72 | : setState 73 | })) 74 | } 75 | })) 76 | } 77 | 78 | // @ts-expect-error we intentionally want to pass undefined to the context 79 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24509#issuecomment-382213106 80 | export const TypeRegistryStoreContext = createContext>(undefined) 81 | 82 | export const TypeRegistryProvider = TypeRegistryStoreContext.Provider 83 | 84 | export const useTypeRegistryStore = (selector: (state: TypeRegistryState) => U, equalityFn?: (a: U, b: U) => boolean) => { 85 | const store = useContext(TypeRegistryStoreContext) 86 | return useStore(store, selector, equalityFn) 87 | } 88 | 89 | function matchTypeComponents ( 90 | value: Value, 91 | path: Path, 92 | registry: TypeRegistryState['registry'] 93 | ): DataType { 94 | let potential: DataType | undefined 95 | for (const T of registry) { 96 | if (T.is(value, path)) { 97 | potential = T 98 | } 99 | } 100 | if (potential === undefined) { 101 | if (typeof value === 'object') { 102 | return objectType as unknown as DataType 103 | } 104 | throw new Error(`No type matched for value: ${value}`) 105 | } 106 | return potential 107 | } 108 | 109 | export function useTypeComponents (value: unknown, path: Path) { 110 | const registry = useTypeRegistryStore(store => store.registry) 111 | return useMemo(() => matchTypeComponents(value, path, registry), [value, path, registry]) 112 | } 113 | -------------------------------------------------------------------------------- /src/theme/base16.ts: -------------------------------------------------------------------------------- 1 | export type NamedColorspace = { 2 | scheme: string 3 | author: string 4 | } & Colorspace 5 | 6 | export type Colorspace = { 7 | base00: string 8 | base01: string 9 | base02: string 10 | base03: string 11 | base04: string 12 | base05: string 13 | base06: string 14 | base07: string 15 | base08: string 16 | base09: string 17 | base0A: string 18 | base0B: string 19 | base0C: string 20 | base0D: string 21 | base0E: string 22 | base0F: string 23 | } 24 | 25 | export const lightColorspace: NamedColorspace = { 26 | scheme: 'Light Theme', 27 | author: 'mac gainor (https://github.com/mac-s-g)', 28 | base00: 'rgba(0, 0, 0, 0)', 29 | base01: 'rgb(245, 245, 245)', 30 | base02: 'rgb(235, 235, 235)', 31 | base03: '#93a1a1', 32 | base04: 'rgba(0, 0, 0, 0.3)', 33 | base05: '#586e75', 34 | base06: '#073642', 35 | base07: '#002b36', 36 | base08: '#d33682', 37 | base09: '#cb4b16', 38 | base0A: '#ffd500', 39 | base0B: '#859900', 40 | base0C: '#6c71c4', 41 | base0D: '#586e75', 42 | base0E: '#2aa198', 43 | base0F: '#268bd2' 44 | } 45 | 46 | export const darkColorspace: NamedColorspace = { 47 | scheme: 'Dark Theme', 48 | author: 'Chris Kempson (http://chriskempson.com)', 49 | base00: '#181818', 50 | base01: '#282828', 51 | base02: '#383838', 52 | base03: '#585858', 53 | base04: '#b8b8b8', 54 | base05: '#d8d8d8', 55 | base06: '#e8e8e8', 56 | base07: '#f8f8f8', 57 | base08: '#ab4642', 58 | base09: '#dc9656', 59 | base0A: '#f7ca88', 60 | base0B: '#a1b56c', 61 | base0C: '#86c1b9', 62 | base0D: '#7cafc2', 63 | base0E: '#ba8baf', 64 | base0F: '#a16946' 65 | } 66 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | import type { SxProps, Theme } from '@mui/material/styles' 2 | import type { ComponentType, CSSProperties, Dispatch, FC, SetStateAction } from 'react' 3 | 4 | import type { Colorspace } from './theme/base16' 5 | 6 | export type Path = (string | number)[] 7 | 8 | /** 9 | * @param path path to the target value 10 | * @param oldValue 11 | * @param newValue 12 | */ 13 | export type JsonViewerOnChange = ( 14 | path: Path, 15 | oldValue: U, 16 | newValue: U 17 | /*, type: ChangeType */ 18 | ) => void 19 | 20 | /** 21 | * @param path path to the target value 22 | * @param value the value to be copied 23 | * @param copy the function to copy the value to clipboard 24 | */ 25 | export type JsonViewerOnCopy = ( 26 | path: Path, 27 | value: U, 28 | copy: (value: string) => Promise 29 | ) => unknown | Promise 30 | 31 | /** 32 | * @param path path to the target value 33 | * @param value the value to be selected 34 | */ 35 | export type JsonViewerOnSelect = ( 36 | path: Path, 37 | value: U, 38 | ) => void 39 | 40 | /** 41 | * @param path path to the parent target where the value will be added 42 | */ 43 | export type JsonViewerOnAdd = ( 44 | path: Path, 45 | ) => void 46 | 47 | /** 48 | * @param path path to the target value 49 | * @param value the value to be deleted 50 | */ 51 | export type JsonViewerOnDelete = ( 52 | path: Path, 53 | value: U, 54 | ) => void 55 | 56 | export interface DataItemProps { 57 | inspect: boolean 58 | setInspect: Dispatch> 59 | value: ValueType 60 | prevValue: ValueType | undefined 61 | path: Path 62 | nestedIndex?: number 63 | } 64 | 65 | export type EditorProps = { 66 | path: Path 67 | value: ValueType 68 | setValue: Dispatch 69 | abortEditing: () => void 70 | commitEditing: (newValue: string) => void 71 | } 72 | 73 | /** 74 | * A data type definition, including methods for checking, serializing, and deserializing values, as well as components for rendering and editing values. 75 | * 76 | * @template ValueType The type of the value represented by this data type 77 | */ 78 | export type DataType = { 79 | /** 80 | * Determines whether a given value belongs to this data type. 81 | * 82 | * @param value The value to check 83 | * @param path The path to the value within the input data structure 84 | * @returns `true` if the value belongs to this data type, `false` otherwise 85 | */ 86 | is: (value: unknown, path: Path) => boolean 87 | /** 88 | * Convert the value of this data type to a string for editing 89 | */ 90 | serialize?: (value: ValueType) => string 91 | /** 92 | * Converts a string representation of a value back to a value of this data type. 93 | * 94 | * Throws an error if the input is invalid, in which case the editor will ignore the change. 95 | */ 96 | deserialize?: (value: string) => ValueType 97 | /** 98 | * The main component used to render a value of this data type. 99 | */ 100 | Component: ComponentType> 101 | /** 102 | * An optional custom editor component for editing values of this data type. 103 | * 104 | * You must also provide a `serialize` and `deserialize` function to enable this feature. 105 | */ 106 | Editor?: ComponentType> 107 | /** 108 | * An optional component to render before the value. 109 | * 110 | * In collapsed mode, it will still be rendered as a prefix. 111 | */ 112 | PreComponent?: ComponentType> 113 | /** 114 | * An optional component to render after the value. 115 | * 116 | * In collapsed mode, it will still be rendered as a suffix. 117 | */ 118 | PostComponent?: ComponentType> 119 | } 120 | 121 | export interface JsonViewerKeyRenderer extends FC { 122 | when (props: DataItemProps): boolean 123 | } 124 | 125 | export type JsonViewerTheme = 'light' | 'dark' | 'auto' | Colorspace 126 | 127 | export type JsonViewerProps = { 128 | /** 129 | * Any value, `object`, `Array`, primitive type, even `Map` or `Set`. 130 | */ 131 | value: T 132 | 133 | /** 134 | * Name of the root value 135 | * 136 | * @default "root" 137 | */ 138 | rootName?: false | string 139 | 140 | /** 141 | * Color theme. 142 | * 143 | * @default 'light' 144 | */ 145 | theme?: JsonViewerTheme 146 | className?: string 147 | style?: CSSProperties 148 | /** 149 | * [The `sx` prop](https://mui.com/system/getting-started/the-sx-prop/) lets you style elements inline, using values from the theme. 150 | * 151 | * @see https://mui.com/system/getting-started/the-sx-prop/ 152 | */ 153 | sx?: SxProps 154 | 155 | /** 156 | * Indent width for nested objects 157 | * 158 | * @default 3 159 | */ 160 | indentWidth?: number 161 | /** 162 | * Customize a key, if `keyRenderer.when` returns `true`. 163 | */ 164 | keyRenderer?: JsonViewerKeyRenderer 165 | /** 166 | * Customize the definition of data types. 167 | * 168 | * @see https://viewer.textea.io/how-to/data-types 169 | */ 170 | valueTypes?: DataType[] 171 | 172 | /** 173 | * Whether enable add feature. 174 | * 175 | * @default false 176 | * */ 177 | enableAdd?: boolean | ((path: Path, currentValue: U) => boolean) 178 | 179 | /** 180 | * Whether enable delete feature. 181 | * 182 | * @default false 183 | * */ 184 | enableDelete?: boolean | ((path: Path, currentValue: U) => boolean) 185 | 186 | /** 187 | * Whether enable clipboard feature. 188 | * 189 | * @default true 190 | */ 191 | enableClipboard?: boolean 192 | 193 | /** 194 | * Whether this value can be edited. 195 | * 196 | * Provide a function to customize this behavior by returning a boolean based on the value and path. 197 | * 198 | * @default false 199 | */ 200 | editable?: boolean | ((path: Path, currentValue: U) => boolean) 201 | 202 | /** Callback when value changed. */ 203 | onChange?: JsonViewerOnChange 204 | /** Callback when value copied, you can use it to customize the copy behavior.
\*Note: you will have to write the data to the clipboard by yourself. */ 205 | onCopy?: JsonViewerOnCopy 206 | /** Callback when value selected. */ 207 | onSelect?: JsonViewerOnSelect 208 | /** Callback when the add button is clicked. This is the function which implements the add feature. Please see the official demo for more details. */ 209 | onAdd?: JsonViewerOnAdd 210 | /** Callback when the delete button is clicked. This is the function which implements the delete feature. Please see the official demo for more details. */ 211 | onDelete?: JsonViewerOnDelete 212 | 213 | /** 214 | * Default inspect depth for nested objects. 215 | * _If the number is set too large, it could result in performance issues._ 216 | * 217 | * @default 5 218 | */ 219 | defaultInspectDepth?: number 220 | 221 | /** 222 | * Default inspect control for nested objects. 223 | * 224 | * Provide a function to customize which fields should be expanded by default. 225 | */ 226 | defaultInspectControl?: (path: Path, value: unknown) => boolean 227 | 228 | /** 229 | * Hide items after reaching the count. 230 | * `Array` and `Object` will be affected. 231 | * 232 | * _If the number is set too large, it could result in performance issues._ 233 | * 234 | * @default 30 235 | */ 236 | maxDisplayLength?: number 237 | 238 | /** 239 | * When an integer value is assigned, arrays will be displayed in groups by count of the value. 240 | * Groups are displayed with bracket notation and can be expanded and collapsed by clicking on the brackets. 241 | * 242 | * @default 100 243 | */ 244 | groupArraysAfterLength?: number 245 | 246 | /** 247 | * Cut off the string after reaching the count. 248 | * Collapsed strings are followed by an ellipsis. 249 | * 250 | * String content can be expanded and collapsed by clicking on the string value. 251 | * 252 | * @default 50 253 | */ 254 | collapseStringsAfterLength?: number | false 255 | 256 | /** 257 | * Whether sort keys through `String.prototype.localeCompare()` 258 | * 259 | * @default false 260 | */ 261 | objectSortKeys?: boolean | ((a: string, b: string) => number) 262 | 263 | /** 264 | * Whether add quotes on keys. 265 | * 266 | * @default true 267 | */ 268 | quotesOnKeys?: boolean 269 | 270 | /** 271 | * Whether display data type labels 272 | * 273 | * @default true 274 | */ 275 | displayDataTypes?: boolean 276 | 277 | /** 278 | * Whether display the size of `Object`, `Array`, `Map` and `Set`. 279 | * 280 | * Provide a function to customize this behavior by returning a boolean based on the value and path. 281 | * 282 | * @default true 283 | */ 284 | displaySize?: boolean | ((path: Path, value: unknown) => boolean) 285 | 286 | /** 287 | * Whether display comma at the end of items. Just like valid JSON. 288 | * 289 | * @default false 290 | */ 291 | displayComma?: boolean 292 | 293 | /** 294 | * Whether to highlight updates. 295 | * 296 | * @default false 297 | */ 298 | highlightUpdates?: boolean 299 | } 300 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import copyToClipboard from 'copy-to-clipboard' 2 | import type { ComponentType } from 'react' 3 | 4 | import type { DataItemProps, DataType, EditorProps, Path } from '../type' 5 | 6 | // reference: https://github.com/immerjs/immer/blob/main/src/utils/common.ts 7 | const objectCtorString = Object.prototype.constructor.toString() 8 | export function isPlainObject (value: any): boolean { 9 | if (!value || typeof value !== 'object') return false 10 | 11 | const proto = Object.getPrototypeOf(value) 12 | if (proto === null) return true 13 | 14 | const Ctor = Object.hasOwnProperty.call(proto, 'constructor') && proto.constructor 15 | if (Ctor === Object) return true 16 | 17 | return typeof Ctor === 'function' && Function.toString.call(Ctor) === objectCtorString 18 | } 19 | 20 | function shouldShallowCopy (value: any) { 21 | if (!value) return false 22 | 23 | return ( 24 | isPlainObject(value) || 25 | Array.isArray(value) || 26 | value instanceof Map || 27 | value instanceof Set 28 | ) 29 | } 30 | 31 | function shallowCopy (value: any) { 32 | if (Array.isArray(value)) return Array.prototype.slice.call(value) 33 | if (value instanceof Set) return new Set(value) 34 | if (value instanceof Map) return new Map(value) 35 | if (typeof value === 'object' && value !== null) { 36 | return Object.assign({}, value) 37 | } 38 | return value 39 | } 40 | 41 | function _applyValue (input: any, path: (string | number)[], value: any, visitedMapping = new Map()) { 42 | if (typeof input !== 'object' || input === null) { 43 | if (path.length !== 0) { 44 | throw new Error('path is incorrect') 45 | } 46 | return value 47 | } 48 | 49 | const shouldCopy = shouldShallowCopy(input) 50 | if (shouldCopy) { 51 | let copiedInput = visitedMapping.get(input) 52 | if (!copiedInput) { 53 | copiedInput = shallowCopy(input) 54 | visitedMapping.set(input, copiedInput) 55 | } 56 | input = copiedInput 57 | } 58 | 59 | const [key, ...restPath] = path 60 | if (key !== undefined) { 61 | if (key === '__proto__') { 62 | throw new TypeError('Modification of prototype is not allowed') 63 | } 64 | if (restPath.length > 0) { 65 | input[key] = _applyValue(input[key], restPath, value, visitedMapping) 66 | } else { 67 | input[key] = value 68 | } 69 | } 70 | return input 71 | } 72 | 73 | /** 74 | * Apply a value to a given path of an object. 75 | */ 76 | export function applyValue (input: any, path: (string | number)[], value: any) { 77 | return _applyValue(input, path, value) 78 | } 79 | 80 | /** 81 | * Delete a value from a given path of an object. 82 | */ 83 | export function deleteValue (input: any, path: (string | number)[], value: any) { 84 | if (typeof input !== 'object' || input === null) { 85 | if (path.length !== 0) { 86 | throw new Error('path is incorrect') 87 | } 88 | return value 89 | } 90 | 91 | const shouldCopy = shouldShallowCopy(input) 92 | if (shouldCopy) input = shallowCopy(input) 93 | 94 | const [key, ...restPath] = path 95 | if (key !== undefined) { 96 | if (key === '__proto__') { 97 | throw new TypeError('Modification of prototype is not allowed') 98 | } 99 | if (restPath.length > 0) { 100 | input[key] = deleteValue(input[key], restPath, value) 101 | } else { 102 | if (Array.isArray(input)) { 103 | input.splice(Number(key), 1) 104 | } else { 105 | delete input[key] 106 | } 107 | } 108 | } 109 | return input 110 | } 111 | 112 | /** 113 | * Define custom data types for any data structure 114 | */ 115 | export function defineDataType ({ 116 | is, 117 | serialize, 118 | deserialize, 119 | Component, 120 | Editor, 121 | PreComponent, 122 | PostComponent 123 | }: { 124 | /** 125 | * Determines whether a given value belongs to this data type. 126 | * 127 | * @param value The value to check 128 | * @param path The path to the value within the input data structure 129 | * @returns `true` if the value belongs to this data type, `false` otherwise 130 | */ 131 | is: (value: unknown, path: Path) => boolean 132 | /** 133 | * Convert the value of this data type to a string for editing 134 | */ 135 | serialize?: (value: ValueType) => string 136 | /** 137 | * Converts a string representation of a value back to a value of this data type. 138 | * 139 | * Throws an error if the input is invalid, in which case the editor will ignore the change. 140 | */ 141 | deserialize?: (value: string) => ValueType 142 | /** 143 | * The main component used to render a value of this data type. 144 | */ 145 | Component: ComponentType> 146 | /** 147 | * An optional custom editor component for editing values of this data type. 148 | * 149 | * You must also provide a `serialize` and `deserialize` function to enable this feature. 150 | */ 151 | Editor?: ComponentType> 152 | /** 153 | * An optional component to render before the value. 154 | * 155 | * In collapsed mode, it will still be rendered as a prefix. 156 | */ 157 | PreComponent?: ComponentType> 158 | /** 159 | * An optional component to render after the value. 160 | * 161 | * In collapsed mode, it will still be rendered as a suffix. 162 | */ 163 | PostComponent?: ComponentType> 164 | }): DataType { 165 | return { 166 | is, 167 | serialize, 168 | deserialize, 169 | Component, 170 | Editor, 171 | PreComponent, 172 | PostComponent 173 | } 174 | } 175 | 176 | export const isCycleReference = ( 177 | root: any, path: (string | number)[], value: unknown): false | string => { 178 | if (root === null || value === null) { 179 | return false 180 | } 181 | if (typeof root !== 'object') { 182 | return false 183 | } 184 | if (typeof value !== 'object') { 185 | return false 186 | } 187 | if (Object.is(root, value) && path.length !== 0) { 188 | return '' 189 | } 190 | const currentPath = [] 191 | const arr = [...path] 192 | let currentRoot = root 193 | while (currentRoot !== value || arr.length !== 0) { 194 | if (typeof currentRoot !== 'object' || currentRoot === null) { 195 | return false 196 | } 197 | if (Object.is(currentRoot, value)) { 198 | return currentPath.reduce((path, value, currentIndex) => { 199 | if (typeof value === 'number') { 200 | return path + `[${value}]` 201 | } 202 | return path + `${currentIndex === 0 ? '' : '.'}${value}` 203 | }, '') 204 | } 205 | const target = arr.shift()! 206 | currentPath.push(target) 207 | currentRoot = currentRoot[target] 208 | } 209 | return false 210 | } 211 | 212 | export function getValueSize (value: any): number { 213 | if (value === null || undefined) { 214 | return 0 215 | } else if (Array.isArray(value)) { 216 | return value.length 217 | } else if (value instanceof Map || value instanceof Set) { 218 | return value.size 219 | } else if (value instanceof Date) { 220 | return 1 221 | } else if (typeof value === 'object') { 222 | return Object.keys(value).length 223 | } else if (typeof value === 'string') { 224 | return value.length 225 | } 226 | return 1 227 | } 228 | 229 | export function segmentArray (arr: T[], size: number): T[][] { 230 | const result: T[][] = [] 231 | let index = 0 232 | while (index < arr.length) { 233 | result.push(arr.slice(index, index + size)) 234 | index += size 235 | } 236 | return result 237 | } 238 | 239 | /** 240 | * A safe version of `JSON.stringify` that handles circular references and BigInts. 241 | * 242 | * *This function might be changed in the future to support more types. Use it with caution.* 243 | * 244 | * @param obj A JavaScript value, usually an object or array, to be converted. 245 | * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. 246 | * @returns 247 | */ 248 | export function safeStringify (obj: any, space?: string | number) { 249 | const seenValues: any[] = [] 250 | 251 | function replacer (key: string | number, value: any) { 252 | // https://github.com/GoogleChromeLabs/jsbi/issues/30 253 | if (typeof value === 'bigint') return value.toString() 254 | 255 | // Map and Set are not supported by JSON.stringify 256 | if (value instanceof Map) { 257 | if ('toJSON' in value && typeof value.toJSON === 'function') return value.toJSON() 258 | if (value.size === 0) return {} 259 | 260 | if (seenValues.includes(value)) return '[Circular]' 261 | seenValues.push(value) 262 | 263 | const entries = Array.from(value.entries()) 264 | if (entries.every(([key]) => typeof key === 'string' || typeof key === 'number')) { 265 | return Object.fromEntries(entries) 266 | } 267 | 268 | // if keys are not string or number, we can't convert to object 269 | // fallback to default behavior 270 | return {} 271 | } 272 | if (value instanceof Set) { 273 | if ('toJSON' in value && typeof value.toJSON === 'function') return value.toJSON() 274 | 275 | if (seenValues.includes(value)) return '[Circular]' 276 | seenValues.push(value) 277 | 278 | return Array.from(value.values()) 279 | } 280 | 281 | // https://stackoverflow.com/a/72457899 282 | if (typeof value === 'object' && value !== null && Object.keys(value).length) { 283 | const stackSize = seenValues.length 284 | if (stackSize) { 285 | // clean up expired references 286 | for (let n = stackSize - 1; n >= 0 && seenValues[n][key] !== value; --n) { seenValues.pop() } 287 | if (seenValues.includes(value)) return '[Circular]' 288 | } 289 | seenValues.push(value) 290 | } 291 | return value 292 | } 293 | 294 | return JSON.stringify(obj, replacer, space) 295 | } 296 | 297 | export async function copyString (value: string) { 298 | if ('clipboard' in navigator) { 299 | try { 300 | await navigator.clipboard.writeText(value) 301 | } catch { 302 | // When navigator.clipboard throws an error, fallback to copy-to-clipboard package 303 | } 304 | } 305 | 306 | // fallback to copy-to-clipboard when navigator.clipboard is not available 307 | copyToClipboard(value) 308 | } 309 | 310 | /** 311 | * Allows handling custom data structures when retrieving values from objects at specific paths. 312 | */ 313 | export interface PathValueCustomGetter { 314 | /** 315 | * Determines if the custom getter should be applied based on the current value and path. 316 | * 317 | * @param {unknown} value - The current value in the object at the given path. 318 | * @param {Path} path - The current path being evaluated. 319 | * @returns {boolean} - True if the custom handler should be used for this value and path. 320 | */ 321 | is: (value: unknown, path: Path) => boolean 322 | 323 | /** 324 | * Custom handler to retrieve a value from a specific key in the current value. 325 | * 326 | * @param {unknown} value - The current value in the object at the given path. 327 | * @param {unknown} key - The key used to retrieve the value from the current value. 328 | * @returns {unknown} - The value retrieved using the custom handler. 329 | */ 330 | handler: (value: unknown, key: unknown) => unknown 331 | } 332 | 333 | export function pathValueDefaultGetter (value: any, key: any): unknown { 334 | if (value === null || value === undefined) { 335 | return null 336 | } 337 | if (value instanceof Map || value instanceof WeakMap) { 338 | return value.get(key) 339 | } 340 | if (value instanceof Set) { 341 | return Array.from(value)[key] 342 | } 343 | if (value instanceof WeakSet) { 344 | throw new Error('WeakSet is not supported') 345 | } 346 | if (Array.isArray(value)) { 347 | return value[Number(key)] 348 | } 349 | if (typeof value === 'object') { 350 | return value[key] 351 | } 352 | return null 353 | } 354 | 355 | /** 356 | * Get the value at a given path in an object. 357 | * Passing custom getters allows you to handle custom data structures. 358 | * @experimental This function is not yet stable and may change in the future. 359 | */ 360 | export function getPathValue ( 361 | obj: T, 362 | path: Path, 363 | customGetters: PathValueCustomGetter[] = [] 364 | ): R | null { 365 | try { 366 | // @ts-ignore 367 | return path.reduce((acc, key, index) => { 368 | if (acc === null || acc === undefined) { 369 | console.error('Invalid path or value encountered at path', path.slice(0, index)) 370 | throw new Error('Invalid path or value encountered') 371 | } 372 | 373 | for (const handler of customGetters) { 374 | const currentPath = path.slice(0, index + 1) 375 | if (handler.is(acc, currentPath)) { 376 | return handler.handler(acc, key) 377 | } 378 | } 379 | return pathValueDefaultGetter(acc, key) 380 | }, obj) as R 381 | } catch (error) { 382 | console.error(error) 383 | return null // or throw error? 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { expectTypeOf } from 'expect-type' 3 | import { describe, expect, it, vi } from 'vitest' 4 | 5 | import type { Path } from '../src' 6 | import { defineDataType, JsonViewer } from '../src' 7 | 8 | function aPlusB (a: number, b: number) { 9 | return a + b 10 | } 11 | 12 | const loopObject = { 13 | foo: 1, 14 | goo: 'string' 15 | } as Record 16 | 17 | loopObject.self = loopObject 18 | 19 | const loopArray = [ 20 | loopObject 21 | ] 22 | 23 | loopArray[1] = loopArray 24 | 25 | const longArray = Array.from({ length: 1000 }).map((_, i) => i) 26 | const map = new Map() 27 | map.set('foo', 1) 28 | map.set('goo', 'hello') 29 | map.set({}, 'world') 30 | 31 | const set = new Set([1, 2, 3]) 32 | 33 | const superLongString = '1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' 34 | 35 | const full = { 36 | loopObject, 37 | loopArray, 38 | longArray, 39 | string: 'this is a string', 40 | integer: 42, 41 | array: [19, 19, 810, 'test', NaN], 42 | nestedArray: [ 43 | [1, 2], 44 | [3, 4] 45 | ], 46 | map, 47 | set, 48 | float: 114.514, 49 | undefined, 50 | superLongString, 51 | object: { 52 | 'first-child': true, 53 | 'second-child': false, 54 | 'last-child': null 55 | }, 56 | fn: aPlusB, 57 | string_number: '1234', 58 | timer: 0, 59 | date: new Date('Tue Sep 13 2022 14:07:44 GMT-0500 (Central Daylight Time)'), 60 | bigint: 123456789087654321n 61 | } 62 | 63 | describe('render ', () => { 64 | it('render undefined', () => { 65 | render() 66 | }) 67 | 68 | it('render null', () => { 69 | render() 70 | }) 71 | 72 | it('render null', () => { 73 | render() 74 | }) 75 | 76 | it('render NaN', () => { 77 | render() 78 | }) 79 | 80 | it('render number', () => { 81 | render() 82 | render() 83 | render() 84 | render() 85 | render() 86 | render() 87 | }) 88 | 89 | it('render bigint', () => { 90 | render() 91 | }) 92 | 93 | it('render array', () => { 94 | render() 95 | render() 96 | }) 97 | 98 | it('render Set', () => { 99 | render() 100 | }) 101 | 102 | it('render Map', () => { 103 | render( 104 | ([['foo', 1], ['goo', 2]])} 106 | /> 107 | ) 108 | render( 109 | ([[[], 1], [{}, 2]])} 111 | /> 112 | ) 113 | }) 114 | 115 | it('render object', () => { 116 | render() 117 | render( 118 | 127 | ) 128 | }) 129 | 130 | it('render function', () => { 131 | render( 132 | 137 | ) 138 | render( a + b} />) 139 | }) 140 | 141 | it('render full', () => { 142 | render() 143 | }) 144 | }) 145 | describe('render with multiple instances', () => { 146 | it('render', () => { 147 | const { container } = render( 148 | <> 149 | true, 155 | Component: () => { 156 | return <>first viewer 157 | } 158 | } 159 | ]} 160 | /> 161 | true, 167 | Component: () => { 168 | return <>second viewer 169 | } 170 | } 171 | ]} 172 | /> 173 | 174 | ) 175 | expect(container.children.length).eq(2) 176 | expect(container.children.item(0)!.textContent).eq('first viewer') 177 | expect(container.children.item(1)!.textContent).eq('second viewer') 178 | }) 179 | }) 180 | 181 | describe('render with props', () => { 182 | it('render with quotesOnKeys', () => { 183 | const selection = [true, false] 184 | selection.forEach(quotesOnKeys => { 185 | render() 186 | }) 187 | }) 188 | 189 | it('render with theme', () => { 190 | const selection = [ 191 | 'light', 192 | 'dark', 193 | { 194 | scheme: 'Ocean', 195 | author: 'Chris Kempson (http://chriskempson.com)', 196 | base00: '#2b303b', 197 | base01: '#343d46', 198 | base02: '#4f5b66', 199 | base03: '#65737e', 200 | base04: '#a7adba', 201 | base05: '#c0c5ce', 202 | base06: '#dfe1e8', 203 | base07: '#eff1f5', 204 | base08: '#bf616a', 205 | base09: '#d08770', 206 | base0A: '#ebcb8b', 207 | base0B: '#a3be8c', 208 | base0C: '#96b5b4', 209 | base0D: '#8fa1b3', 210 | base0E: '#b48ead', 211 | base0F: '#ab7967' 212 | } 213 | ] as const 214 | selection.forEach(theme => { 215 | render() 216 | }) 217 | }) 218 | 219 | it('render with objectSortKeys', () => { 220 | const selection = [ 221 | true, 222 | false, 223 | (a: string, b: string) => a.localeCompare(b)] 224 | selection.forEach(objectSortKeys => { 225 | render() 226 | }) 227 | }) 228 | 229 | it('render with rootName false', async () => { 230 | render() 231 | expect((await screen.findByTestId('data-key-pair')).innerText) 232 | .toEqual(undefined) 233 | }) 234 | 235 | it('render with displayDataTypes', async () => { 236 | const selection = [true, false] 237 | selection.forEach(displayDataTypes => { 238 | render() 239 | }) 240 | }) 241 | 242 | it('render with displaySize', async () => { 243 | const selection = [true, false] 244 | selection.forEach(displaySize => { 245 | render() 246 | }) 247 | }) 248 | 249 | it('render with defaultInspectControl', async () => { 250 | const defaultInspectControl = (path: Path, _data: unknown) => { 251 | if (path.length === 0) return true 252 | return path.at(-1)?.toString().endsWith('On') 253 | } 254 | const data = { 255 | foo: { 256 | bar: 'bar' 257 | }, 258 | fooOn: { 259 | barOn: 'barOn' 260 | } 261 | } 262 | const { container } = render( 263 | <> 264 | 271 | 272 | ) 273 | expect(container.children.length).eq(1) 274 | expect(container.children.item(0)!.textContent).eq('{"foo":{…}"fooOn":{"barOn":"barOn"}}') 275 | }) 276 | 277 | it('render with dataTypes', async () => { 278 | render() 279 | render( 280 | typeof value === 'string', 285 | Component: (props) => { 286 | expectTypeOf(props.value).toMatchTypeOf() 287 | return null 288 | } 289 | }, 290 | defineDataType({ 291 | is: (value) => typeof value === 'string', 292 | Component: (props) => { 293 | expectTypeOf(props.value).toMatchTypeOf() 294 | return null 295 | } 296 | }) 297 | ]} 298 | /> 299 | ) 300 | }) 301 | }) 302 | 303 | describe('Expand elements by click on dots', () => { 304 | it('render', () => { 305 | const { container, rerender } = render( 306 | 311 | ) 312 | 313 | let elements = container.getElementsByClassName('data-object-body') 314 | expect(elements.length).eq(1) 315 | expect(elements[0].textContent).eq('…') 316 | fireEvent.click(elements[0]) 317 | 318 | rerender( 319 | 324 | ) 325 | elements = container.getElementsByClassName('data-object-body') 326 | expect(elements.length).eq(0) 327 | 328 | elements = container.getElementsByClassName('data-object') 329 | expect(elements.length).eq(1) 330 | expect(elements[0].children.length).eq(2) 331 | }) 332 | }) 333 | 334 | describe('test functions', () => { 335 | const func1 = function (...args: any[]) { 336 | console.log(args) 337 | return '111' 338 | } 339 | 340 | function func2 (...args: any[]) { 341 | console.log(args) 342 | return '222' 343 | } 344 | 345 | const dataProvider = [ 346 | [ 347 | function (...args: any) { 348 | console.log(args) 349 | return '333' 350 | }, 351 | '(...args) {', 352 | ` 353 | console.log(args); 354 | return "333"; 355 | ` 356 | ], 357 | [ 358 | func1, 359 | '(...args) {', 360 | ` 361 | console.log(args); 362 | return "111"; 363 | ` 364 | ], 365 | [ 366 | func2, 367 | 'func2(...args) {', 368 | ` 369 | console.log(args); 370 | return "222"; 371 | ` 372 | ], 373 | [ 374 | // eslint-disable-next-line unused-imports/no-unused-vars 375 | (...args: any) => console.log('555'), 376 | '(...args) => {', 377 | ' console.log("555")' 378 | ], 379 | [ 380 | (...args: any) => { 381 | console.log(args) 382 | return '666' 383 | }, 384 | '(...args) => {', 385 | ` { 386 | console.log(args); 387 | return "666"; 388 | }` 389 | ], 390 | [ 391 | // eslint-disable-next-line unused-imports/no-unused-vars 392 | function (a: number, b: number) { 393 | throw Error('Be careful to use the function just as value in useState() hook') 394 | }, 395 | '(a, b) {', 396 | ` 397 | throw Error("Be careful to use the function just as value in useState() hook"); 398 | ` 399 | ], 400 | [ 401 | ({ prop1, prop2, ...other }: any) => { 402 | console.log(prop1, prop2, other) 403 | return '777' 404 | }, 405 | '({ prop1, prop2, ...other }) => {', 406 | ` { 407 | console.log(prop1, prop2, other); 408 | return "777"; 409 | }` 410 | ], 411 | [ 412 | { 413 | func: ({ prop1, prop2, ...other }: any) => { 414 | console.log(prop1, prop2, other) 415 | return '888' 416 | } 417 | }, 418 | '({ prop1, prop2, ...other }) => {', 419 | ` { 420 | console.log(prop1, prop2, other); 421 | return "888"; 422 | }` 423 | ], 424 | [ 425 | // @ts-ignore 426 | function (e, n) { return e + n }, 427 | '(e, n) {', 428 | ` 429 | return e + n; 430 | ` 431 | ] 432 | ] 433 | for (const iteration of dataProvider) { 434 | it('render', () => { 435 | const { container } = render( 436 | 440 | ) 441 | expect(container.children.length).eq(1) 442 | const functionName = container.getElementsByClassName('data-function-start') 443 | expect(functionName.length).eq(1) 444 | expect(functionName[0].textContent).eq(iteration[1]) 445 | 446 | const functionBody = container.getElementsByClassName('data-function') 447 | expect(functionBody.length).eq(1) 448 | expect(functionBody[0].textContent).eq(iteration[2]) 449 | }) 450 | } 451 | }) 452 | 453 | describe('Expand function by click on dots', () => { 454 | it('render', () => { 455 | const { container, rerender } = render( 456 | console.log(e)} 459 | defaultInspectDepth={0} 460 | /> 461 | ) 462 | 463 | let elements = container.getElementsByClassName('data-function-body') 464 | expect(elements.length).eq(1) 465 | expect(elements[0].textContent).eq('…') 466 | fireEvent.click(elements[0]) 467 | 468 | rerender( 469 | console.log(e)} 472 | defaultInspectDepth={0} 473 | /> 474 | ) 475 | elements = container.getElementsByClassName('data-function-body') 476 | expect(elements.length).eq(0) 477 | 478 | elements = container.getElementsByClassName('data-function') 479 | expect(elements.length).eq(1) 480 | expect(elements[0].children.length).eq(0) 481 | expect(elements[0].textContent).not.eq('…') 482 | }) 483 | }) 484 | 485 | describe('See empty iterables', () => { 486 | it('Array', () => { 487 | const { container } = render( 488 | 493 | ) 494 | 495 | let elements = container.getElementsByClassName('data-object-body') 496 | expect(elements.length).eq(0) 497 | elements = container.getElementsByClassName('data-object-start') 498 | expect(elements.length).eq(1) 499 | elements = container.getElementsByClassName('data-object-end') 500 | expect(elements.length).eq(1) 501 | }) 502 | it('Object', () => { 503 | const { container } = render( 504 | 509 | ) 510 | 511 | let elements = container.getElementsByClassName('data-object-body') 512 | expect(elements.length).eq(0) 513 | elements = container.getElementsByClassName('data-object-start') 514 | expect(elements.length).eq(1) 515 | elements = container.getElementsByClassName('data-object-end') 516 | expect(elements.length).eq(1) 517 | }) 518 | it('Map', () => { 519 | const { container } = render( 520 | 525 | ) 526 | 527 | let elements = container.getElementsByClassName('data-object-body') 528 | expect(elements.length).eq(0) 529 | elements = container.getElementsByClassName('data-object-start') 530 | expect(elements.length).eq(1) 531 | elements = container.getElementsByClassName('data-object-end') 532 | expect(elements.length).eq(1) 533 | }) 534 | it('Set', () => { 535 | const { container } = render( 536 | 541 | ) 542 | 543 | let elements = container.getElementsByClassName('data-object-body') 544 | expect(elements.length).eq(0) 545 | elements = container.getElementsByClassName('data-object-start') 546 | expect(elements.length).eq(1) 547 | elements = container.getElementsByClassName('data-object-end') 548 | expect(elements.length).eq(1) 549 | }) 550 | }) 551 | 552 | describe('Click on empty iterables', () => { 553 | it('Array', () => { 554 | const Component = () => ( 555 | 560 | ) 561 | const { container, rerender } = render() 562 | 563 | // Click on start brace 564 | let elements = container.getElementsByClassName('data-object-start') 565 | expect(elements.length).eq(1) 566 | fireEvent.click(elements[0]) 567 | 568 | rerender() 569 | elements = container.getElementsByClassName('data-object-body') 570 | expect(elements.length).eq(0) 571 | 572 | // Click on end brace 573 | elements = container.getElementsByClassName('data-object-end') 574 | expect(elements.length).eq(1) 575 | fireEvent.click(elements[0]) 576 | 577 | rerender() 578 | elements = container.getElementsByClassName('data-object-body') 579 | expect(elements.length).eq(0) 580 | }) 581 | it('Object', () => { 582 | const Component = () => ( 583 | 588 | ) 589 | const { container, rerender } = render() 590 | 591 | // Click on start brace 592 | let elements = container.getElementsByClassName('data-object-start') 593 | expect(elements.length).eq(1) 594 | fireEvent.click(elements[0]) 595 | 596 | rerender() 597 | elements = container.getElementsByClassName('data-object-body') 598 | expect(elements.length).eq(0) 599 | 600 | // Click on end brace 601 | elements = container.getElementsByClassName('data-object-end') 602 | expect(elements.length).eq(1) 603 | fireEvent.click(elements[0]) 604 | 605 | rerender() 606 | elements = container.getElementsByClassName('data-object-body') 607 | expect(elements.length).eq(0) 608 | }) 609 | it('Map', () => { 610 | const Component = () => ( 611 | 616 | ) 617 | const { container, rerender } = render() 618 | 619 | // Click on start brace 620 | let elements = container.getElementsByClassName('data-object-start') 621 | expect(elements.length).eq(1) 622 | fireEvent.click(elements[0]) 623 | 624 | rerender() 625 | elements = container.getElementsByClassName('data-object-body') 626 | expect(elements.length).eq(0) 627 | 628 | // Click on end brace 629 | elements = container.getElementsByClassName('data-object-end') 630 | expect(elements.length).eq(1) 631 | fireEvent.click(elements[0]) 632 | 633 | rerender() 634 | elements = container.getElementsByClassName('data-object-body') 635 | expect(elements.length).eq(0) 636 | }) 637 | it('Set', () => { 638 | const Component = () => ( 639 | 644 | ) 645 | const { container, rerender } = render() 646 | 647 | // Click on start brace 648 | let elements = container.getElementsByClassName('data-object-start') 649 | expect(elements.length).eq(1) 650 | fireEvent.click(elements[0]) 651 | 652 | rerender() 653 | elements = container.getElementsByClassName('data-object-body') 654 | expect(elements.length).eq(0) 655 | 656 | // Click on end brace 657 | elements = container.getElementsByClassName('data-object-end') 658 | expect(elements.length).eq(1) 659 | fireEvent.click(elements[0]) 660 | 661 | rerender() 662 | elements = container.getElementsByClassName('data-object-body') 663 | expect(elements.length).eq(0) 664 | }) 665 | }) 666 | 667 | describe('Show three dots after string collapsing', () => { 668 | it('render', () => { 669 | const Component = () => ( 670 | 675 | ) 676 | const { container, rerender } = render() 677 | 678 | let elements = container.getElementsByClassName('string-value') 679 | expect(elements.length).eq(1) 680 | expect(elements[0].children.length).eq(1) 681 | expect(elements[0].textContent).eq('"string…"') 682 | fireEvent.click(elements[0].children[0]) 683 | 684 | rerender() 685 | elements = container.getElementsByClassName('string-value') 686 | expect(elements.length).eq(1) 687 | expect(elements[0].children.length).eq(1) 688 | expect(elements[0].textContent).eq('"string string string"') 689 | fireEvent.click(elements[0].children[0]) 690 | 691 | rerender() 692 | elements = container.getElementsByClassName('string-value') 693 | expect(elements.length).eq(1) 694 | expect(elements[0].children.length).eq(1) 695 | expect(elements[0].textContent).eq('"string…"') 696 | }) 697 | }) 698 | 699 | describe('Nested long array', () => { 700 | it('use correct key in nested long array', () => { 701 | const longArray: any[] = Array.from({ length: 100 }).map((_, i) => i) 702 | longArray.push(Array.from({ length: 50 }).map(() => Array.from({ length: 50 }).map((_, i) => i))) 703 | 704 | const onSelect = vi.fn() 705 | const Component = () => ( 706 | 712 | ) 713 | const { container, rerender } = render() 714 | 715 | const lastGroup = container.querySelector('[data-testid="data-key-pair"] > .data-object > .data-key-pair:last-child') 716 | const threeDot = lastGroup.querySelector('.data-object-body') 717 | fireEvent.click(threeDot) // expand last group 718 | 719 | rerender() 720 | const lastGroupBody = lastGroup.querySelector('.data-key-pair') 721 | const key = lastGroupBody.querySelector('.data-key') 722 | expect(key.textContent).eq('100:[50 Items') 723 | 724 | const lastLastGroup = lastGroupBody.querySelector('.data-object > .data-key-pair:last-child') 725 | const lastThreeDot = lastLastGroup.querySelector('.data-object-body') 726 | fireEvent.click(lastThreeDot) // expand last last group 727 | 728 | rerender() 729 | const lastLastSecondGroup = lastLastGroup.children.item(1).children.item(1) 730 | const lastLastKey = lastLastSecondGroup.querySelector('.data-key') 731 | expect(lastLastKey.textContent).eq('41:[50 Items') 732 | 733 | const targetContainer = lastLastSecondGroup.children.item(1).children.item(1) 734 | const targetThreeDot = targetContainer.querySelector('.data-object-body') 735 | fireEvent.click(targetThreeDot) // expand target group 736 | 737 | rerender() 738 | const targetArray = targetContainer.children.item(1) 739 | const targetKV = targetArray.children.item(1) 740 | expect(targetKV.textContent).eq('21:int21') 741 | 742 | const targetValue = targetKV.children.item(1) 743 | fireEvent.click(targetValue) 744 | expect(onSelect).toBeCalledWith([100, 41, 21], 21) 745 | }) 746 | }) 747 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | 3 | Object.defineProperty(window, 'matchMedia', { 4 | writable: true, 5 | value: vi.fn().mockImplementation(query => ({ 6 | matches: false, 7 | media: query, 8 | onchange: null, 9 | addEventListener: vi.fn(), 10 | removeEventListener: vi.fn(), 11 | dispatchEvent: vi.fn() 12 | })) 13 | }) 14 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@textea/json-viewer": ["../src/index"] 5 | }, 6 | "jsx": "preserve", 7 | "incremental": true, 8 | "noEmit": true, 9 | "target": "ES2020", 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "strict": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true 20 | }, 21 | "include": ["**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /tests/util.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | 3 | import { applyValue, deleteValue, isCycleReference } from '../src' 4 | import { getPathValue, pathValueDefaultGetter, safeStringify, segmentArray } from '../src/utils' 5 | 6 | describe('function applyValue', () => { 7 | const patches: any[] = [{}, undefined, 1, '2', 3n, 0.4] 8 | test('incorrect arguments', () => { 9 | expect(() => { 10 | applyValue({}, ['not', 'exist'], 1) 11 | }).toThrow() 12 | expect(() => { 13 | applyValue(1, ['not', 'exist'], 1) 14 | }).toThrow() 15 | }) 16 | 17 | test('prototype polluting', () => { 18 | const original = {} 19 | expect(() => { 20 | applyValue(original, ['__proto__', 'polluted'], 1) 21 | }).toThrow() 22 | }) 23 | 24 | test('undefined', () => { 25 | patches.forEach(patch => { 26 | const newValue = applyValue(undefined, [], patch) 27 | expect(newValue).is.eq(patch) 28 | }) 29 | }) 30 | 31 | test('null', () => { 32 | patches.forEach(patch => { 33 | const newValue = applyValue(null, [], patch) 34 | expect(newValue).is.eq(patch) 35 | }) 36 | }) 37 | 38 | test('number', () => { 39 | patches.forEach(patch => { 40 | const newValue = applyValue(1, [], patch) 41 | expect(newValue).is.eq(patch) 42 | }) 43 | patches.forEach(patch => { 44 | const newValue = applyValue(114514, [], patch) 45 | expect(newValue).is.eq(patch) 46 | }) 47 | }) 48 | 49 | test('string', () => { 50 | patches.forEach(patch => { 51 | const newValue = applyValue('', [], patch) 52 | expect(newValue).is.eq(patch) 53 | }) 54 | }) 55 | 56 | test('object', () => { 57 | const original = { 58 | foo: 1 59 | } 60 | const newValue = applyValue(original, ['foo'], 2) 61 | expect(newValue).is.deep.eq({ 62 | foo: 2 63 | }) 64 | }) 65 | 66 | test('object nested', () => { 67 | const original = { 68 | foo: { 69 | bar: { 70 | baz: 1 71 | } 72 | } 73 | } 74 | const newValue = applyValue(original, ['foo', 'bar', 'baz'], 2) 75 | expect(newValue).is.deep.eq({ 76 | foo: { 77 | bar: { 78 | baz: 2 79 | } 80 | } 81 | }) 82 | }) 83 | 84 | test('array', () => { 85 | const original = [1, 2, 3] 86 | const newValue = applyValue(original, [1], 4) 87 | expect(newValue).is.deep.eq([1, 4, 3]) 88 | }) 89 | 90 | test('array nested', () => { 91 | const original = [1, [2, [3, 4]]] 92 | const newValue = applyValue(original, [1, 1, 1], 5) 93 | expect(newValue).is.deep.eq([1, [2, [3, 5]]]) 94 | }) 95 | }) 96 | 97 | describe('function deleteValue', () => { 98 | const patches: any[] = [{}, undefined, 1, '2', 3n, 0.4] 99 | test('incorrect arguments', () => { 100 | expect(() => { 101 | deleteValue({}, ['not', 'exist'], 1) 102 | }).toThrow() 103 | expect(() => { 104 | deleteValue(1, ['not', 'exist'], 1) 105 | }).toThrow() 106 | }) 107 | 108 | test('prototype polluting', () => { 109 | const original = {} 110 | expect(() => { 111 | deleteValue(original, ['__proto__', 'polluted'], 1) 112 | }).toThrow() 113 | }) 114 | 115 | test('undefined', () => { 116 | patches.forEach(patch => { 117 | const newValue = deleteValue(undefined, [], patch) 118 | expect(newValue).is.eq(patch) 119 | }) 120 | }) 121 | 122 | test('null', () => { 123 | patches.forEach(patch => { 124 | const newValue = deleteValue(null, [], patch) 125 | expect(newValue).is.eq(patch) 126 | }) 127 | }) 128 | 129 | test('number', () => { 130 | patches.forEach(patch => { 131 | const newValue = deleteValue(1, [], patch) 132 | expect(newValue).is.eq(patch) 133 | }) 134 | patches.forEach(patch => { 135 | const newValue = deleteValue(114514, [], patch) 136 | expect(newValue).is.eq(patch) 137 | }) 138 | }) 139 | 140 | test('string', () => { 141 | patches.forEach(patch => { 142 | const newValue = deleteValue('', [], patch) 143 | expect(newValue).is.eq(patch) 144 | }) 145 | }) 146 | 147 | test('object', () => { 148 | const original = { 149 | foo: 1, 150 | bar: 2 151 | } 152 | const newValue = deleteValue(original, ['foo'], 1) 153 | expect(newValue).is.deep.eq({ 154 | bar: 2 155 | }) 156 | }) 157 | 158 | test('object nested', () => { 159 | const original = { 160 | foo: { 161 | bar: { 162 | baz: 1, 163 | qux: 2 164 | } 165 | } 166 | } 167 | const newValue = deleteValue(original, ['foo', 'bar', 'qux'], 2) 168 | expect(newValue).is.deep.eq({ 169 | foo: { 170 | bar: { 171 | baz: 1 172 | } 173 | } 174 | }) 175 | }) 176 | 177 | test('array', () => { 178 | const original = [1, 2, 3] 179 | const newValue = deleteValue(original, [1], 2) 180 | expect(newValue).is.deep.eq([1, 3]) 181 | }) 182 | 183 | test('array nested', () => { 184 | const original = [1, [2, [3, 4]]] 185 | const newValue = deleteValue(original, [1, 1, 1], 4) 186 | expect(newValue).is.deep.eq([1, [2, [3]]]) 187 | }) 188 | }) 189 | 190 | describe('function isCycleReference', () => { 191 | test('root is leaf', () => { 192 | const root = { 193 | leaf: {} 194 | } 195 | root.leaf = root 196 | expect(isCycleReference(root, ['leaf'], root.leaf)).to.eq('') 197 | }) 198 | 199 | test('branch is leaf', () => { 200 | const root = { 201 | a: { 202 | b: { 203 | c: {} 204 | } 205 | } 206 | } 207 | root.a.b.c = root.a.b 208 | expect(isCycleReference(root, ['a', 'b', 'c'], root.a.b.c)).to.eq('a.b') 209 | }) 210 | }) 211 | 212 | describe('function segmentArray', () => { 213 | test('case 1', () => { 214 | const array = [1, 2, 3, 4, 5] 215 | const result = segmentArray(array, 2) 216 | expect(result).to.deep.eq([ 217 | [1, 2], 218 | [3, 4], 219 | [5] 220 | ]) 221 | }) 222 | 223 | test('case 2', () => { 224 | const array = [1, 2, 3, 4, 5] 225 | const result = segmentArray(array, 3) 226 | expect(result).to.deep.eq([ 227 | [1, 2, 3], 228 | [4, 5] 229 | ]) 230 | }) 231 | 232 | test('case 3', () => { 233 | const array = [1, 2, 3, 4, 5] 234 | const result = segmentArray(array, 5) 235 | expect(result).to.deep.eq([ 236 | [1, 2, 3, 4, 5] 237 | ]) 238 | }) 239 | 240 | test('case 4', () => { 241 | const array = [1, 2, 3, 4, 5] 242 | const result = segmentArray(array, 6) 243 | expect(result).to.deep.eq([ 244 | [1, 2, 3, 4, 5] 245 | ]) 246 | }) 247 | }) 248 | 249 | describe('function circularStringify', () => { 250 | test('should works as JSON.stringify', () => { 251 | const obj = { foo: 1, bar: 2 } 252 | expect(safeStringify(obj)).to.eq(JSON.stringify(obj)) 253 | }) 254 | 255 | test('should works with circular reference in object', () => { 256 | const obj = { 257 | foo: 1, 258 | bar: { 259 | foo: 2, 260 | bar: null 261 | } 262 | } 263 | obj.bar.bar = obj.bar 264 | expect(safeStringify(obj)).to.eq('{"foo":1,"bar":{"foo":2,"bar":"[Circular]"}}') 265 | }) 266 | 267 | test('should works with circular reference in array', () => { 268 | const array = [1, 2, 3, 4, 5] 269 | // @ts-expect-error ignore 270 | array[2] = array 271 | expect(safeStringify(array)).to.eq('[1,2,"[Circular]",4,5]') 272 | }) 273 | 274 | test('should works with complex circular object', () => { 275 | const obj = { 276 | a: { 277 | b: { 278 | c: 1, 279 | d: 2 280 | } 281 | }, 282 | e: { 283 | f: 3, 284 | g: 4 285 | } 286 | } 287 | // @ts-expect-error ignore 288 | obj.a.b.e = obj.e 289 | // @ts-expect-error ignore 290 | obj.e.g = obj.a.b 291 | expect(safeStringify(obj)).to.eq('{"a":{"b":{"c":1,"d":2,"e":{"f":3,"g":"[Circular]"}}},"e":{"f":3,"g":"[Circular]"}}') 292 | }) 293 | 294 | test('should works with ES6 Map', () => { 295 | const map = new Map() 296 | map.set('foo', 1) 297 | map.set('bar', 2) 298 | expect(safeStringify(map)).to.eq('{"foo":1,"bar":2}') 299 | }) 300 | 301 | test('should works with ES6 Set', () => { 302 | const set = new Set() 303 | set.add(1) 304 | set.add(2) 305 | expect(safeStringify(set)).to.eq('[1,2]') 306 | }) 307 | 308 | test('should works with ES6 Map with circular reference', () => { 309 | const map = new Map() 310 | map.set('foo', 1) 311 | map.set('bar', map) 312 | expect(safeStringify(map)).to.eq('{"foo":1,"bar":"[Circular]"}') 313 | }) 314 | 315 | test('should works with ES6 Set with circular reference', () => { 316 | const set = new Set() 317 | set.add(1) 318 | set.add(set) 319 | expect(safeStringify(set)).to.eq('[1,"[Circular]"]') 320 | }) 321 | }) 322 | 323 | describe('function pathValueDefaultGetter', () => { 324 | test('should works with object', () => { 325 | const obj = { 326 | foo: 1 327 | } 328 | expect(pathValueDefaultGetter(obj, 'foo')).to.eq(1) 329 | }) 330 | 331 | test('should works with array', () => { 332 | const array = [1, 2, 3, 4, 5] 333 | expect(pathValueDefaultGetter(array, 2)).to.eq(3) 334 | }) 335 | 336 | test('should works with Map', () => { 337 | const map = new Map() 338 | map.set('foo', 1) 339 | map.set('bar', 2) 340 | expect(pathValueDefaultGetter(map, 'foo')).to.eq(1) 341 | expect(pathValueDefaultGetter(map, 'not exist')).to.eq(undefined) 342 | }) 343 | 344 | test('should works with WeakMap', () => { 345 | const map = new WeakMap() 346 | const key = {} 347 | map.set(key, 1) 348 | expect(pathValueDefaultGetter(map, key)).to.eq(1) 349 | }) 350 | 351 | test('should works with Set', () => { 352 | const set = new Set() 353 | set.add(1) 354 | set.add(2) 355 | expect(pathValueDefaultGetter(set, 1)).to.eq(2) 356 | }) 357 | 358 | test('should not works with WeakSet', () => { 359 | const set = new WeakSet() 360 | set.add({}) 361 | expect(() => { 362 | pathValueDefaultGetter(set, [0]) 363 | }).toThrow() 364 | }) 365 | }) 366 | 367 | describe('function getPathValue', () => { 368 | test('should works with object', () => { 369 | const obj = { 370 | foo: { 371 | bar: { 372 | baz: 1 373 | } 374 | } 375 | } 376 | expect(getPathValue(obj, ['foo', 'bar', 'baz'])).to.eq(1) 377 | }) 378 | 379 | test('should works with array', () => { 380 | const array = [1, [2, [3, 4]]] 381 | expect(getPathValue(array, [1, 1, 1])).to.eq(4) 382 | }) 383 | 384 | test('should works with Map', () => { 385 | const map = new Map() 386 | map.set('foo', 1) 387 | map.set('bar', 2) 388 | expect(getPathValue(map, ['foo'])).to.eq(1) 389 | expect(getPathValue(map, ['not exist'])).to.eq(undefined) 390 | }) 391 | 392 | test('should works with WeakMap', () => { 393 | const map = new WeakMap() 394 | const key = {} 395 | map.set(key, 1) 396 | // @ts-ignore 397 | expect(getPathValue(map, [key])).to.eq(1) 398 | }) 399 | 400 | test('should works with Set', () => { 401 | const set = new Set() 402 | set.add(1) 403 | set.add(2) 404 | expect(getPathValue(set, [1])).to.eq(2) 405 | }) 406 | 407 | test('should not works with WeakSet', () => { 408 | const set = new WeakSet() 409 | set.add({}) 410 | expect(getPathValue(set, [0])).to.eq(null) 411 | }) 412 | }) 413 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "outDir": "./dist/out/", 5 | "rootDir": "./src/", 6 | "jsx": "react-jsx", 7 | "strict": true, 8 | "noEmit": true, 9 | "incremental": false, 10 | "declaration": true, 11 | "moduleResolution": "node", 12 | "allowSyntheticDefaultImports": true 13 | }, 14 | "include": ["./src"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "target": "ESNext", 5 | "module": "NodeNext", 6 | "moduleResolution": "Node" 7 | }, 8 | "include": ["src", "rollup.config.ts", "vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | setupFiles: './tests/setup.ts', 8 | globals: true, 9 | environment: 'jsdom', 10 | coverage: { 11 | include: ['src'], 12 | ignoreEmptyLines: false 13 | } 14 | } 15 | }) 16 | --------------------------------------------------------------------------------