├── .browserslistrc ├── .commitlintrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build-and-test.yml │ ├── codeql-analysis.yml │ ├── lint-check.yml │ └── release-please.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .release-please-manifest.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.2.4.cjs ├── .yarnrc.yml ├── 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 ├── theme.config.js └── tsconfig.json ├── package.json ├── 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 └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | # sync with https://github.com/mui/material-ui/blob/v5.12.1/.browserslistrc#L12-L27 2 | and_chr 91 3 | and_ff 89 4 | and_qq 10.4 5 | and_uc 12.12 6 | # > I recommend removing android from the list because from polyfill side it is very close to and_chr or chrome 7 | # https://github.com/browserslist/browserslist/issues/753 8 | # android 91 9 | baidu 7.12 10 | chrome 90 11 | edge 91 12 | firefox 78 13 | # 12.4 but 12.2-12.5 are treated equally in caniuse-lite. 14 | # Though caniuse-lite does not supporting finding an exact version in a range which is why `12.4` would result in "Unknown version 12.4 of ios_saf" 15 | ios_saf 12.2 16 | kaios 2.5 17 | op_mini all 18 | # 76 -> 73 as caniuse database doesn't have 76 :/ 19 | op_mob 73 20 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-angular" 4 | ], 5 | "rules": { 6 | "scope-case": [ 7 | 2, 8 | "always", 9 | "pascal-case" 10 | ], 11 | "type-enum": [ 12 | 2, 13 | "always", 14 | [ 15 | "chore", 16 | "build", 17 | "ci", 18 | "docs", 19 | "feat", 20 | "fix", 21 | "perf", 22 | "refactor", 23 | "revert", 24 | "style", 25 | "test", 26 | "types", 27 | "workflow", 28 | "wip" 29 | ] 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.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 | .yarn 3 | node_modules 4 | -------------------------------------------------------------------------------- /.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/member-delimiter-style': [ 122 | 'error', { 123 | multiline: { 124 | delimiter: 'none', requireLast: true 125 | }, 126 | singleline: { 127 | delimiter: 'semi', requireLast: false 128 | } 129 | }], 130 | '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports', disallowTypeAnnotations: false }], 131 | '@typescript-eslint/explicit-function-return-type': 'off', 132 | 'react-hooks/rules-of-hooks': 'error', 133 | 'react-hooks/exhaustive-deps': 'warn', 134 | 'no-restricted-imports': 'off', 135 | '@typescript-eslint/no-restricted-imports': [ 136 | 'error', 137 | { 138 | patterns: [ 139 | { 140 | group: ['**/dist'], 141 | message: 'Don\'t import from dist', 142 | allowTypeImports: false 143 | } 144 | ] 145 | } 146 | ] 147 | }, 148 | overrides: [ 149 | { 150 | files: ['*.d.ts'], 151 | rules: { 152 | 'no-undef': 'off' 153 | } 154 | }, 155 | { 156 | files: ['*.test.ts', '*.test.tsx'], env: { jest: true } 157 | } 158 | ] 159 | } 160 | -------------------------------------------------------------------------------- /.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 | open-pull-requests-limit: 3 9 | -------------------------------------------------------------------------------- /.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@v3 21 | with: 22 | fetch-depth: 0 23 | - name: Use Node.js LTS 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'yarn' 28 | - name: Get yarn cache directory path 29 | id: yarn-cache-dir-path 30 | run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT 31 | - uses: actions/cache@v3 32 | id: yarn-cache 33 | with: 34 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 35 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-yarn- 38 | - name: Install Dependencies 39 | run: yarn install 40 | - name: Build 41 | run: yarn build 42 | - name: Test with Coverage 43 | run: yarn coverage 44 | - uses: codecov/codecov-action@v3 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | -------------------------------------------------------------------------------- /.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@v3 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v2 32 | with: 33 | languages: ${{ matrix.language }} 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v2 36 | - name: Perform CodeQL Analysis 37 | uses: github/codeql-action/analyze@v2 38 | with: 39 | category: "/language:${{matrix.language}}" 40 | -------------------------------------------------------------------------------- /.github/workflows/lint-check.yml: -------------------------------------------------------------------------------- 1 | name: Lint Check 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 | paths: 15 | - '**.tsx?' 16 | - '**.jsx?' 17 | jobs: 18 | lint-check: 19 | name: ESLint check 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | - name: Use Node.js LTS 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version-file: '.nvmrc' 29 | cache: 'yarn' 30 | - name: Get yarn cache directory path 31 | id: yarn-cache-dir-path 32 | run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT 33 | - uses: actions/cache@v3 34 | id: yarn-cache 35 | with: 36 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 37 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-yarn- 40 | - name: Install Dependencies 41 | run: yarn install 42 | - name: Lint 43 | run: yarn lint:ci 44 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release-please: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | releases_created: ${{ steps.release.outputs.releases_created }} 11 | steps: 12 | - uses: google-github-actions/release-please-action@v3 13 | id: release 14 | with: 15 | command: manifest 16 | token: ${{secrets.GITHUB_TOKEN}} 17 | publish-json-viewer: 18 | name: 'Publish @textea/json-viewer' 19 | needs: release-please 20 | if: ${{ needs.release-please.outputs.releases_created }} 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | - name: Use Node.js LTS 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version-file: '.nvmrc' 29 | cache: 'yarn' 30 | registry-url: 'https://registry.npmjs.org' 31 | - name: Get yarn cache directory path 32 | id: yarn-cache-dir-path 33 | run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT 34 | - uses: actions/cache@v3 35 | id: yarn-release-cache 36 | with: 37 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 38 | key: ${{ runner.os }}-yarn-release-${{ hashFiles('**/yarn.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-yarn-release 41 | - name: Install Dependencies 42 | run: | 43 | yarn install 44 | - name: Prepack 45 | run: yarn run prepack 46 | - name: Build 47 | run: npm run build 48 | - name: Delete Scripts 49 | run: npm pkg delete scripts 50 | - name: Delete Workspaces 51 | run: npm pkg delete workspaces 52 | - uses: JS-DevTools/npm-publish@v1 53 | name: Publish to npm 54 | with: 55 | access: 'public' 56 | token: ${{ secrets.NPM_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.yarn/* 2 | **/!.yarn/patches 3 | **/!.yarn/plugins 4 | **/!.yarn/releases 5 | **/!.yarn/sdks 6 | **/!.yarn/versions 7 | 8 | # Swap the comments on the following lines if you don't wish to use zero-installs 9 | # Documentation here: https://yarnpkg.com/features/zero-installs 10 | #!.yarn/cache 11 | .pnp.* 12 | 13 | dist 14 | tsconfig.tsbuildinfo 15 | .eslintcache 16 | node_modules 17 | 18 | .next 19 | .vercel 20 | coverage 21 | 22 | .idea 23 | .vscode 24 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .husky/ 3 | package.json 4 | yarn-error.log 5 | yarn.lock 6 | 7 | *generated* 8 | storybook-static 9 | 10 | # document 11 | *.mdx 12 | 13 | # VSCode personal settings 14 | .vscode/launch.json 15 | .vscode/tasks.json 16 | 17 | # JetBrain personal settings 18 | .idea 19 | 20 | # testing 21 | /reports 22 | /junit.xml 23 | 24 | # Build out 25 | dist/* 26 | /build 27 | /storybook-static 28 | 29 | # Environment files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # Block-chain contract files 36 | /contracts 37 | 38 | # Temp profiles 39 | .firefox 40 | .chrome 41 | 42 | # Following content is copied from https://github.com/github/gitignore/blob/master/Node.gitignore 43 | # Logs 44 | logs 45 | *.log 46 | npm-debug.log* 47 | yarn-debug.log* 48 | yarn-error.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 | 153 | # yarn v2 154 | .yarn/cache 155 | .yarn/unplugged 156 | .yarn/build-state.yml 157 | .yarn/install-state.gz 158 | .pnp.* 159 | -------------------------------------------------------------------------------- /.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 | ".": "3.0.0" 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nmMode: hardlinks-local 2 | 3 | nodeLinker: node-modules 4 | 5 | npmAuthToken: "${NODE_AUTH_TOKEN:-NONE}" 6 | 7 | npmPublishAccess: public 8 | 9 | npmPublishRegistry: "https://registry.npmjs.org" 10 | 11 | plugins: 12 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 13 | spec: "@yarnpkg/plugin-interactive-tools" 14 | 15 | yarnPath: .yarn/releases/yarn-3.2.4.cjs 16 | -------------------------------------------------------------------------------- /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/4fab3ed5-7084-449d-9fc9-12df09108301/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-v2-afaey9?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.3.0", 16 | "nextra": "^2.3.0", 17 | "nextra-theme-docs": "^2.3.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^18.15.3", 23 | "@types/react": "^18.0.33", 24 | "@types/react-dom": "^18.0.11", 25 | "typescript": "^5.0.3" 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 a key, if `keyRenderer.when` returns `true`. | 13 | | `valueTypes` | `ValueTypes` | - | Customize the definition of data types. See [Defining Data Types](/how-to/data-types) | 14 | | `onChange` | `(path, oldVal, newVal) => void` | - | Callback when value changed. | 15 | | `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. | 16 | | `onSelect` | `(path, value) => void` | - | Callback when value selected. | 17 | | `enableClipboard` | `boolean` | `true` | Whether enable clipboard feature. | 18 | | `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. | 19 | | `defaultInspectDepth` | `number` | 5 | Default inspect depth for nested objects.

_\* If the number is set too large, it could result in performance issues._ | 20 | | `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._ | 21 | | `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. | 22 | | `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. | 23 | | `objectSortKeys` | `boolean` | `false` | Whether sort keys through `String.prototype.localeCompare()` | 24 | | `quotesOnKeys` | `boolean` | `true` | Whether add quotes on keys. | 25 | | `displayDataTypes` | `boolean` | `true` | Whether display data type labels. | 26 | | `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. | 27 | | `highlightUpdates` | `boolean` | `true` | Whether to highlight updates. | 28 | 29 | ### Mapping from [`mac-s-g/react-json-view`](https://github.com/mac-s-g/react-json-view) 30 | 31 | | Name | Type | Alternative | 32 | | ----------- | --------- | ------------------------------------------------- | 33 | | `name` | `string` | See `rootName` | 34 | | `src` | `any` | See `value` | 35 | | `collapsed` | `boolean` | Set `defaultInspectDepth` to `0` to collapse all. | 36 | 37 | ## Type Declaration 38 | 39 | See [src/type.ts](https://github.com/TexteaInc/json-viewer/blob/main/src/type.ts) 40 | -------------------------------------------------------------------------------- /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 | JsonViewerOnChange, 20 | JsonViewerTheme 21 | } from '@textea/json-viewer' 22 | import { 23 | applyValue, 24 | defineDataType, 25 | JsonViewer, 26 | stringType 27 | } from '@textea/json-viewer' 28 | import Image from 'next/image' 29 | import Link from 'next/link' 30 | import type { FC } from 'react' 31 | import { useCallback, useEffect, useState } from 'react' 32 | 33 | import { ocean } from '../../lib/shared' 34 | 35 | const allowedDomains = ['i.imgur.com'] 36 | 37 | // this url is copied from: https://beta.reactjs.org/learn/passing-props-to-a-component 38 | const avatar = 'https://i.imgur.com/1bX5QH6.jpg' 39 | 40 | function aPlusB (a: number, b: number) { 41 | return a + b 42 | } 43 | const aPlusBConst = function (a: number, b: number) { 44 | return a + b 45 | } 46 | 47 | const loopObject = { 48 | foo: 42, 49 | goo: 'Lorem Ipsum' 50 | } as Record 51 | 52 | loopObject.self = loopObject 53 | 54 | const loopArray = [ 55 | loopObject 56 | ] 57 | 58 | loopArray[1] = loopArray 59 | 60 | const longArray = Array.from({ length: 1000 }).map((_, i) => i) 61 | const map = new Map() 62 | map.set('foo', 1) 63 | map.set('goo', 'hello') 64 | map.set({}, 'world') 65 | 66 | const set = new Set([1, 2, 3]) 67 | 68 | const superLongString = '1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' 69 | 70 | const example = { 71 | avatar, 72 | string: 'Lorem ipsum dolor sit amet', 73 | integer: 42, 74 | url: new URL('https://example.com'), 75 | float: 114.514, 76 | bigint: 123456789087654321n, 77 | undefined, 78 | timer: 0, 79 | date: new Date('Tue Sep 13 2022 14:07:44 GMT-0500 (Central Daylight Time)'), 80 | link: 'http://example.com', 81 | emptyArray: [], 82 | array: [19, 19, 810, 'test', NaN], 83 | emptyObject: {}, 84 | object: { 85 | foo: true, 86 | bar: false, 87 | last: null 88 | }, 89 | emptyMap: new Map(), 90 | map, 91 | emptySet: new Set(), 92 | set, 93 | loopObject, 94 | loopArray, 95 | longArray, 96 | nestedArray: [ 97 | [1, 2], 98 | [3, 4] 99 | ], 100 | superLongString, 101 | function: aPlusB, 102 | constFunction: aPlusBConst, 103 | anonymousFunction: function (a: number, b: number) { 104 | return a + b 105 | }, 106 | shortFunction: (arg1: any, arg2: any) => console.log(arg1, arg2), 107 | shortLongFunction: (arg1: any, arg2: any) => { 108 | console.log(arg1, arg2) 109 | return '123' 110 | }, 111 | string_number: '1234' 112 | } 113 | 114 | const KeyRenderer: JsonViewerKeyRenderer = ({ path }) => { 115 | return ( 116 | "{path.slice(-1)}" 117 | ) 118 | } 119 | KeyRenderer.when = (props) => props.value === 114.514 120 | 121 | const imageDataType = defineDataType({ 122 | is: (value) => { 123 | if (typeof value === 'string') { 124 | try { 125 | const url = new URL(value) 126 | return allowedDomains.includes(url.host) && url.pathname.endsWith('.jpg') 127 | } catch (_) { 128 | return false 129 | } 130 | } 131 | return false 132 | }, 133 | Component: (props) => { 134 | return ( 135 | {props.value} 142 | ) 143 | } 144 | }) 145 | 146 | const LinkIcon = (props: SvgIconProps) => ( 147 | // 148 | 149 | 150 | 151 | 152 | 153 | 154 | ) 155 | 156 | const linkType: DataType = { 157 | ...stringType, 158 | is (value) { 159 | return typeof value === 'string' && value.startsWith('http') 160 | }, 161 | PostComponent: (props) => ( 162 | 169 | 170 | Open 171 | 172 | 173 | 174 | ) 175 | } 176 | 177 | const urlType = defineDataType({ 178 | is: (data) => data instanceof URL, 179 | Component: (props) => { 180 | const url = props.value.toString() 181 | return ( 182 | 192 | {url} 193 | 194 | ) 195 | } 196 | }) 197 | 198 | const IndexPage: FC = () => { 199 | const [indent, setIndent] = useState(3) 200 | const [groupArraysAfterLength, setGroupArraysAfterLength] = useState(100) 201 | const [themeKey, setThemeKey] = useState('light') 202 | const [theme, setTheme] = useState('light') 203 | const [src, setSrc] = useState(() => example) 204 | const [displayDataTypes, setDisplayDataTypes] = useState(true) 205 | const [displaySize, setDisplaySize] = useState(true) 206 | const [editable, setEditable] = useState(true) 207 | const [highlightUpdates, setHighlightUpdates] = useState(true) 208 | useEffect(() => { 209 | const loop = () => { 210 | setSrc(src => ({ 211 | ...src, 212 | timer: src.timer + 1 213 | })) 214 | } 215 | const id = setInterval(loop, 1000) 216 | return () => clearInterval(id) 217 | }, []) 218 | return ( 219 |
220 | 221 | 222 | 228 | JSON viewer 229 | 230 | 231 | 232 | 245 | setEditable(event.target.checked)} 250 | /> 251 | )} 252 | label='Editable' 253 | /> 254 | setHighlightUpdates(event.target.checked)} 259 | /> 260 | )} 261 | label='Highlight Updates' 262 | /> 263 | setDisplayDataTypes(event.target.checked)} 268 | /> 269 | )} 270 | label='DisplayDataTypes' 271 | /> 272 | setDisplaySize(event.target.checked)} 277 | /> 278 | )} 279 | label='DisplayObjectSize' 280 | /> 281 | { 288 | const indent = parseInt(event.target.value) 289 | if (indent > -1 && indent < 10) { 290 | setIndent(indent) 291 | } 292 | } 293 | } 294 | /> 295 | { 302 | const groupArraysAfterLength = parseInt(event.target.value) 303 | if (groupArraysAfterLength > -1 && groupArraysAfterLength < 500) { 304 | setGroupArraysAfterLength(groupArraysAfterLength) 305 | } 306 | } 307 | } 308 | /> 309 | 312 | Theme 313 | 331 | 332 | 333 | ( 350 | (path, oldValue, newValue) => { 351 | setSrc(src => applyValue(src, path, newValue)) 352 | }, [] 353 | ) 354 | } 355 | sx={{ 356 | paddingLeft: 2 357 | }} 358 | /> 359 |
360 | ) 361 | } 362 | 363 | export default IndexPage 364 | -------------------------------------------------------------------------------- /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 balue 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.value` - The value to render. 29 | - `props.inspect` - A Boolean flag indicating whether the value is being inspected (expanded). 30 | - `props.setInspect` - A function that can be used to toggle the inspect state. 31 | 32 | #### `PreComponent` and `PostComponent` 33 | 34 | 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. 35 | 36 | #### `Editor` 37 | 38 | 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. 39 | 40 | ## Examples 41 | 42 | ### Adding support for image 43 | 44 | Suppose you have a JSON object that includes an image URL: 45 | 46 | ```json 47 | { 48 | "image": "https://i.imgur.com/1bX5QH6.jpg" 49 | } 50 | ``` 51 | 52 | If you want to preview images directly in your JSON, you can define a data type for images: 53 | 54 | 55 | 56 | 57 | ```jsx 58 | import { defineDataType } from '@textea/json-viewer' 59 | 60 | const imageType = defineDataType({ 61 | is: (value) => { 62 | if (typeof value !== 'string') return false 63 | try { 64 | const url = new URL(value) 65 | return url.pathname.endsWith('.jpg') 66 | } catch { 67 | return false 68 | } 69 | }, 70 | Component: (props) => { 71 | return ( 72 | {props.value} 79 | ) 80 | } 81 | }) 82 | ``` 83 | 84 | 85 | 86 | 87 | ```tsx 88 | import { defineDataType } from '@textea/json-viewer' 89 | 90 | const imageType = defineDataType({ 91 | is: (value) => { 92 | if (typeof value !== 'string') return false 93 | try { 94 | const url = new URL(value) 95 | return url.pathname.endsWith('.jpg') 96 | } catch { 97 | return false 98 | } 99 | }, 100 | Component: (props) => { 101 | return ( 102 | {props.value} 109 | ) 110 | } 111 | }) 112 | ``` 113 | 114 | 115 | 116 | 117 | And then use it like this: 118 | 119 | ```jsx {3,5} 120 | 126 | ``` 127 | 128 |
129 | 130 | 131 | ### Adding support for `URL` 132 | 133 | Let's add support for the [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) API. 134 | 135 | 136 | 137 | ```jsx 138 | import { defineDataType } from '@textea/json-viewer' 139 | 140 | const urlType = defineDataType({ 141 | is: (value) => value instanceof URL, 142 | Component: (props) => { 143 | const url = props.value.toString() 144 | return ( 145 | 155 | {url} 156 | 157 | ) 158 | } 159 | }) 160 | ``` 161 | 162 | 163 | 164 | ```tsx 165 | import { defineDataType } from '@textea/json-viewer' 166 | 167 | const urlType = defineDataType({ 168 | is: (value) => value instanceof URL, 169 | Component: (props) => { 170 | const url = props.value.toString() 171 | return ( 172 | 182 | {url} 183 | 184 | ) 185 | } 186 | }) 187 | ``` 188 | 189 | 190 | 191 | And then use it like this: 192 | 193 | ```jsx {4,6} 194 | 201 | ``` 202 | 203 | It should looks like this 🎉 204 | 205 |
206 | 207 | 208 | ### Adding widgets to the value 209 | 210 | Sometimes you might want to add a button to the value. 211 | 212 | 213 | 214 | In this example, we will **extend the built-in `stringType`** and add a button to open the link in a new tab. 215 | 216 | 217 | 218 | ```jsx 219 | import { defineDataType, JsonViewer, stringType } from '@textea/json-viewer' 220 | import { Button } from '@mui/material' 221 | 222 | const linkType = defineDataType({ 223 | ...stringType, 224 | is (value) { 225 | return typeof value === 'string' && value.startsWith('http') 226 | }, 227 | PostComponent: (props) => ( 228 | 242 | ) 243 | }) 244 | ``` 245 | 246 | 247 | 248 | ```tsx 249 | import { defineDataType, JsonViewer, stringType } from '@textea/json-viewer' 250 | import { Button } from '@mui/material' 251 | 252 | const linkType = defineDataType({ 253 | ...stringType, 254 | is (value) { 255 | return typeof value === 'string' && value.startsWith('http') 256 | }, 257 | PostComponent: (props) => ( 258 | 272 | ) 273 | }) 274 | ``` 275 | 276 | 277 | 278 | ### Customize the `Date` format 279 | 280 | 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. 281 | 282 | 283 | 284 | ```jsx 285 | import { defineEasyType } from '@textea/json-viewer' 286 | 287 | const myDateType = defineEasyType({ 288 | is: (value) => value instanceof Date, 289 | type: 'date', 290 | colorKey: 'base0D', 291 | Renderer: ({ value }) => <>{value.toISOString().split('T')[0]} 292 | }) 293 | ``` 294 | 295 | 296 | 297 | ```tsx 298 | import { defineEasyType } from '@textea/json-viewer' 299 | 300 | const myDateType = defineEasyType({ 301 | is: (value) => value instanceof Date, 302 | type: 'date', 303 | colorKey: 'base0D', 304 | Renderer: ({ value }) => <>{value.toISOString().split('T')[0]} 305 | }) 306 | ``` 307 | 308 | 309 | 310 | 311 | -------------------------------------------------------------------------------- /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-pair` 73 | - `data-type-label` 74 | - `data-object` 75 | - `data-object-start` 76 | - `data-object-body` 77 | - `data-object-end` 78 | - `data-function` 79 | - `data-function-start` 80 | - `data-function-body` 81 | - `data-function-end` 82 | - `data-value-fallback` 83 | 84 | ```css 85 | .json-viewer .data-object-start { 86 | color: red; 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /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](/api) 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 | } 4 | -------------------------------------------------------------------------------- /docs/pages/migration/migration-v3.mdx: -------------------------------------------------------------------------------- 1 | # Migrating from v2 to v3 2 | 3 | ### Updating your dependencies 4 | 5 | The very first thing you will need to do is to update your dependencies. 6 | 7 | #### Update version 8 | 9 | ```bash 10 | npm install @textea/json-viewer@^3.0.0 11 | ``` 12 | 13 | #### Install peer dependencies 14 | 15 | This package is using [Material-UI](https://mui.com/) as the base component library, so you need to install it and its peer dependencies. 16 | Starting from v3, these dependencies are no longer included in the package's dependencies. 17 | 18 | ```bash 19 | npm install @mui/material @emotion/react @emotion/styled 20 | ``` 21 | 22 | ### Handling breaking changes 23 | 24 | #### Check browser compatibility 25 | 26 | This package was set to support `ES5` by default, but it's no longer the case.\ 27 | 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/). 28 | 29 | #### Use `defineDataType` instead of `createDataType` 30 | 31 | `serialize` and `deserialize` have been added to datatype to support editing feature on any data type. 32 | 33 | 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). 34 | 35 | ```diff 36 | - createDataType( 37 | - (value) => typeof value === 'string' && value.startsWith('https://i.imgur.com'), 38 | - (props) => {props.value} 39 | - ) 40 | + defineDataType({ 41 | + is: (value) => typeof value === 'string' && value.startsWith('https://i.imgur.com'), 42 | + Component: (props) => {props.value} 43 | + }) 44 | ``` 45 | 46 | #### Rename `displayObjectSize` to `displaySize` 47 | 48 | `displayObjectSize` has been renamed to `displaySize` to describe the prop's purpose more accurately. 49 | 50 | ```diff 51 | 56 | ``` 57 | 58 | Now you can provide a function to customize this behavior by returning a boolean based on the value and path. 59 | 60 | ```jsx {2-6} 61 | { 63 | if (Array.isArray(value)) return false 64 | if (value instanceof Map) return true 65 | return true 66 | }} 67 | value={value} 68 | /> 69 | ``` 70 | -------------------------------------------------------------------------------- /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 | "version": "3.0.0", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/TexteaInc/json-viewer.git" 9 | }, 10 | "author": "himself65 ", 11 | "bugs": "https://github.com/TexteaInc/json-viewer/issues", 12 | "homepage": "https://github.com/TexteaInc/json-viewer#readme", 13 | "keywords": [ 14 | "react-18", 15 | "react", 16 | "react-json", 17 | "react-json-viewer", 18 | "array-viewer", 19 | "component", 20 | "interactive", 21 | "interactive-json", 22 | "json", 23 | "json-component", 24 | "json-display", 25 | "json-tree", 26 | "json-view", 27 | "json-viewer", 28 | "json-inspector", 29 | "json-tree", 30 | "tree", 31 | "tree-view", 32 | "treeview" 33 | ], 34 | "types": "dist/index.d.ts", 35 | "jsdelivr": "dist/browser.js", 36 | "browser": { 37 | "vanilla": "dist/browser.js" 38 | }, 39 | "main": "dist/index.js", 40 | "module": "dist/index.mjs", 41 | "exports": { 42 | ".": { 43 | "types": "./dist/index.d.ts", 44 | "import": "./dist/index.mjs", 45 | "require": "./dist/index.js" 46 | } 47 | }, 48 | "files": [ 49 | "dist" 50 | ], 51 | "scripts": { 52 | "test": "vitest", 53 | "test:run": "vitest run", 54 | "dev": "yarn workspace @textea/json-viewer-docs dev", 55 | "coverage": "vitest run --coverage", 56 | "postinstall": "husky install", 57 | "prepack": "pinst --disable", 58 | "postpack": "pinst --enable", 59 | "lint": "npx eslint . --ext .ts,.tsx,.js,.jsx --cache --fix", 60 | "lint:ci": "npx eslint . --ext .ts,.tsx,.js,.jsx --cache --max-warnings 0", 61 | "build": "tsc && rollup -c rollup.config.ts --configPlugin swc3" 62 | }, 63 | "dependencies": { 64 | "clsx": "^1.2.1", 65 | "copy-to-clipboard": "^3.3.3", 66 | "zustand": "^4.3.7" 67 | }, 68 | "lint-staged": { 69 | "!*.{ts,tsx,js,jsx}": "prettier --write --ignore-unknown", 70 | "*.{ts,tsx,js,jsx}": "npx eslint --cache --fix" 71 | }, 72 | "peerDependencies": { 73 | "@emotion/react": "^11", 74 | "@emotion/styled": "^11", 75 | "@mui/material": "^5", 76 | "react": "^17 || ^18", 77 | "react-dom": "^17 || ^18" 78 | }, 79 | "devDependencies": { 80 | "@commitlint/cli": "^17.5.1", 81 | "@commitlint/config-angular": "^17.6.3", 82 | "@emotion/react": "^11.10.6", 83 | "@emotion/styled": "^11.10.6", 84 | "@mui/material": "^5.12.0", 85 | "@rollup/plugin-alias": "^5.0.0", 86 | "@rollup/plugin-commonjs": "^24.1.0", 87 | "@rollup/plugin-node-resolve": "^15.0.1", 88 | "@rollup/plugin-replace": "^5.0.2", 89 | "@swc/core": "^1.3.51", 90 | "@swc/helpers": "^0.5.0", 91 | "@testing-library/react": "^14.0.0", 92 | "@types/node": "^18.15.3", 93 | "@types/react": "^18.0.33", 94 | "@types/react-dom": "^18.0.11", 95 | "@typescript-eslint/eslint-plugin": "^5.57.0", 96 | "@typescript-eslint/parser": "^5.58.0", 97 | "@vitejs/plugin-react": "^4.0.0", 98 | "@vitest/coverage-c8": "^0.29.8", 99 | "@vitest/ui": "^0.31.0", 100 | "eslint": "^8.37.0", 101 | "eslint-config-standard": "^17.0.0", 102 | "eslint-plugin-cypress": "^2.13.2", 103 | "eslint-plugin-import": "^2.27.5", 104 | "eslint-plugin-n": "^15.7.0", 105 | "eslint-plugin-promise": "^6.1.1", 106 | "eslint-plugin-react": "^7.32.2", 107 | "eslint-plugin-react-hooks": "^4.6.0", 108 | "eslint-plugin-simple-import-sort": "^10.0.0", 109 | "eslint-plugin-unused-imports": "^2.0.0", 110 | "expect-type": "^0.15.0", 111 | "husky": "^8.0.3", 112 | "jsdom": "^21.1.1", 113 | "lint-staged": "^13.2.1", 114 | "pinst": "^3.0.0", 115 | "prettier": "^2.8.7", 116 | "react": "^18.2.0", 117 | "react-dom": "^18.2.0", 118 | "rollup": "^3.20.2", 119 | "rollup-plugin-dts": "^5.3.0", 120 | "rollup-plugin-swc3": "^0.8.1", 121 | "ts-node": "^10.9.1", 122 | "typescript": "^5.0.3", 123 | "vite": "^4.2.1", 124 | "vitest": "^0.29.8" 125 | }, 126 | "resolutions": { 127 | "browserslist": "4.21.5", 128 | "caniuse-lite": "1.0.30001481" 129 | }, 130 | "packageManager": "yarn@3.2.4", 131 | "workspaces": [ 132 | "docs" 133 | ] 134 | } 135 | -------------------------------------------------------------------------------- /public/avatar-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yell0wflash/json-viewer/43c6b1e77819853f1b1084132fa24a9673d3db8f/public/avatar-preview.png -------------------------------------------------------------------------------- /public/ocean-theme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yell0wflash/json-viewer/43c6b1e77819853f1b1084132fa24a9673d3db8f/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 | "packages": { 6 | ".": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /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 | })) 89 | ] 90 | } 91 | } 92 | 93 | const dtsMatrix = (): RollupOptions[] => { 94 | return [...dtsOutput.values()].flatMap(([input, output]) => ({ 95 | input, 96 | cache, 97 | output: { 98 | file: resolve(outputDir, `${output}.d.ts`), 99 | format: 'es' 100 | }, 101 | plugins: [ 102 | dts() 103 | ] 104 | })) 105 | } 106 | 107 | const build: RollupOptions[] = [ 108 | buildMatrix('./src/index.tsx', 'index', { 109 | format: ['es', 'umd'], 110 | browser: false, 111 | dts: true 112 | }), 113 | buildMatrix('./src/browser.tsx', 'browser', { 114 | format: ['es', 'umd'], 115 | browser: true, 116 | dts: true 117 | }), 118 | ...dtsMatrix() 119 | ] 120 | 121 | export default build 122 | -------------------------------------------------------------------------------- /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 type { JsonViewerProps } from './type' 6 | 7 | const getElementFromConfig = (el?: string | Element) => (el 8 | ? (typeof el === 'string' ? document.querySelector(el) : el) 9 | : document.getElementById('json-viewer')) 10 | 11 | export default class JsonViewer { 12 | private props: JsonViewerProps 13 | private root?: Root 14 | 15 | static Component = JsonViewerComponent 16 | 17 | constructor (props: JsonViewerProps) { 18 | this.props = props 19 | } 20 | 21 | render (el?: string | Element) { 22 | const container = getElementFromConfig(el) 23 | 24 | if (container) { 25 | this.root = createRoot(container) 26 | this.root.render() 27 | } 28 | } 29 | 30 | destroy () { 31 | if (this.root) { 32 | this.root.unmount() 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /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 } from '../utils' 12 | import { 13 | CheckIcon, 14 | ChevronRightIcon, 15 | CloseIcon, 16 | ContentCopyIcon, 17 | EditIcon, 18 | ExpandMoreIcon 19 | } from './Icons' 20 | import { DataBox } from './mui/DataBox' 21 | 22 | export type DataKeyPairProps = { 23 | value: unknown 24 | prevValue?: unknown 25 | nestedIndex?: number 26 | editable?: boolean 27 | path: (string | number)[] 28 | } 29 | 30 | type IconBoxProps = ComponentProps 31 | 32 | const IconBox: FC = (props) => ( 33 | 42 | ) 43 | 44 | export const DataKeyPair: FC = (props) => { 45 | const { value, prevValue, path, nestedIndex } = props 46 | const { Component, PreComponent, PostComponent, Editor, serialize, deserialize } = useTypeComponents(value, path) 47 | 48 | const propsEditable = props.editable ?? undefined 49 | const storeEditable = useJsonViewerStore(store => store.editable) 50 | const editable = useMemo(() => { 51 | if (storeEditable === false) { 52 | return false 53 | } 54 | if (propsEditable === false) { 55 | // props.editable is false which means we cannot provide the suitable way to edit it 56 | return false 57 | } 58 | if (typeof storeEditable === 'function') { 59 | return !!storeEditable(path, value) 60 | } 61 | return storeEditable 62 | }, [path, propsEditable, storeEditable, value]) 63 | const [tempValue, setTempValue] = useState('') 64 | const depth = path.length 65 | const key = path[depth - 1] 66 | const hoverPath = useJsonViewerStore(store => store.hoverPath) 67 | const isHover = useMemo(() => { 68 | return hoverPath && path.every( 69 | (value, index) => value === hoverPath.path[index] && nestedIndex === 70 | hoverPath.nestedIndex) 71 | }, [hoverPath, path, nestedIndex]) 72 | const setHover = useJsonViewerStore(store => store.setHover) 73 | const root = useJsonViewerStore(store => store.value) 74 | const [inspect, setInspect] = useInspect(path, value, nestedIndex) 75 | const [editing, setEditing] = useState(false) 76 | const onChange = useJsonViewerStore(store => store.onChange) 77 | const keyColor = useTextColor() 78 | const numberKeyColor = useJsonViewerStore(store => store.colorspace.base0C) 79 | const highlightColor = useJsonViewerStore(store => store.colorspace.base0A) 80 | const quotesOnKeys = useJsonViewerStore(store => store.quotesOnKeys) 81 | const rootName = useJsonViewerStore(store => store.rootName) 82 | const isRoot = root === value 83 | const isNumberKey = Number.isInteger(Number(key)) 84 | 85 | const enableClipboard = useJsonViewerStore(store => store.enableClipboard) 86 | const { copy, copied } = useClipboard() 87 | 88 | const highlightUpdates = useJsonViewerStore(store => store.highlightUpdates) 89 | const isHighlight = useMemo(() => { 90 | if (!highlightUpdates || prevValue === undefined) return false 91 | 92 | // highlight if value type changed 93 | if (typeof value !== typeof prevValue) { 94 | return true 95 | } 96 | 97 | if (typeof value === 'number') { 98 | // notice: NaN !== NaN 99 | if (isNaN(value) && isNaN(prevValue as number)) return false 100 | return value !== prevValue 101 | } 102 | 103 | // highlight if isArray changed 104 | if (Array.isArray(value) !== Array.isArray(prevValue)) { 105 | return true 106 | } 107 | 108 | // not highlight object/function 109 | // deep compare they will be slow 110 | if (typeof value === 'object' || typeof value === 'function') { 111 | return false 112 | } 113 | 114 | // highlight if not equal 115 | if (value !== prevValue) { 116 | return true 117 | } 118 | 119 | return false 120 | }, [highlightUpdates, prevValue, value]) 121 | const highlightContainer = useRef() 122 | useEffect(() => { 123 | if (highlightContainer.current && isHighlight && 'animate' in highlightContainer.current) { 124 | highlightContainer.current.animate( 125 | [ 126 | { backgroundColor: highlightColor }, 127 | { backgroundColor: '' } 128 | ], 129 | { 130 | duration: 1000, 131 | easing: 'ease-in' 132 | } 133 | ) 134 | } 135 | }, [highlightColor, isHighlight, prevValue, value]) 136 | 137 | const actionIcons = useMemo(() => { 138 | if (editing && deserialize) { 139 | return ( 140 | <> 141 | 142 | { 145 | // abort editing 146 | setEditing(false) 147 | setTempValue('') 148 | }} 149 | /> 150 | 151 | 152 | { 155 | // finish editing, save data 156 | setEditing(false) 157 | try { 158 | const newValue = deserialize(tempValue) 159 | onChange(path, value, newValue) 160 | } catch (e) { 161 | // do nothing when deserialize failed 162 | } 163 | }} 164 | /> 165 | 166 | 167 | ) 168 | } 169 | return ( 170 | <> 171 | {enableClipboard && ( 172 | { 174 | event.preventDefault() 175 | try { 176 | copy(path, value, copyString) 177 | } catch (e) { 178 | // in some case, this will throw error 179 | // fixme: `useAlert` hook 180 | console.error(e) 181 | } 182 | }} 183 | > 184 | { 185 | copied 186 | ? 187 | : 188 | } 189 | 190 | )} 191 | {(Editor && editable && serialize && deserialize) && 192 | ( 193 | { 195 | event.preventDefault() 196 | setTempValue(serialize(value)) 197 | setEditing(true) 198 | }} 199 | > 200 | 201 | 202 | )} 203 | 204 | ) 205 | }, 206 | [ 207 | Editor, 208 | serialize, 209 | deserialize, 210 | copied, 211 | copy, 212 | editable, 213 | editing, 214 | enableClipboard, 215 | onChange, 216 | path, 217 | tempValue, 218 | value 219 | ]) 220 | 221 | const isEmptyValue = useMemo(() => getValueSize(value) === 0, [value]) 222 | const expandable = !isEmptyValue && !!(PreComponent && PostComponent) 223 | const KeyRenderer = useJsonViewerStore(store => store.keyRenderer) 224 | const downstreamProps: DataItemProps = useMemo(() => ({ 225 | path, 226 | inspect, 227 | setInspect, 228 | value, 229 | prevValue 230 | }), [inspect, path, setInspect, value, prevValue]) 231 | return ( 232 | setHover(path, nestedIndex), 238 | [setHover, path, nestedIndex]) 239 | } 240 | > 241 | ) => { 252 | if (event.isDefaultPrevented()) { 253 | return 254 | } 255 | if (!isEmptyValue) { 256 | setInspect(state => !state) 257 | } 258 | }, [isEmptyValue, setInspect]) 259 | } 260 | > 261 | { 262 | expandable 263 | ? (inspect 264 | ? ( 265 | 271 | ) 272 | : ( 273 | 279 | ) 280 | ) 281 | : null 282 | } 283 | 284 | { 285 | (isRoot 286 | ? rootName !== false 287 | ? (quotesOnKeys ? <>"{rootName}" : <>{rootName}) 288 | : null 289 | : KeyRenderer.when(downstreamProps) 290 | ? 291 | : nestedIndex === undefined && ( 292 | isNumberKey 293 | ? {key} 294 | : quotesOnKeys ? <>"{key}" : <>{key} 295 | ) 296 | ) 297 | } 298 | 299 | { 300 | ( 301 | isRoot 302 | ? (rootName !== false && :) 303 | : nestedIndex === undefined && ( 304 | : 305 | ) 306 | ) 307 | } 308 | {PreComponent && } 309 | {(isHover && expandable && inspect) && actionIcons} 310 | 311 | { 312 | (editing && editable) 313 | ? (Editor && ) 314 | : (Component) 315 | ? 316 | : ( 317 | 318 | {`fallback: ${value}`} 319 | 320 | ) 321 | } 322 | {PostComponent && } 323 | {(isHover && expandable && !inspect) && actionIcons} 324 | {(isHover && !expandable) && actionIcons} 325 | 326 | ) 327 | } 328 | -------------------------------------------------------------------------------- /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 | 2 | import { defineEasyType } from './defineEasyType' 3 | 4 | export const booleanType = defineEasyType({ 5 | is: (value) => typeof value === 'boolean', 6 | type: 'bool', 7 | colorKey: 'base0E', 8 | serialize: value => value.toString(), 9 | deserialize: value => { 10 | if (value === 'true') return true 11 | if (value === 'false') return false 12 | throw new Error('Invalid boolean value') 13 | }, 14 | Renderer: ({ value }) => <>{value ? 'true' : 'false'} 15 | }) 16 | -------------------------------------------------------------------------------- /src/components/DataTypes/Date.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { defineEasyType } from './defineEasyType' 3 | 4 | const displayOptions: Intl.DateTimeFormatOptions = { 5 | weekday: 'short', 6 | year: 'numeric', 7 | month: 'short', 8 | day: 'numeric', 9 | hour: '2-digit', 10 | minute: '2-digit' 11 | } 12 | 13 | export const dateType = defineEasyType({ 14 | is: (value) => value instanceof Date, 15 | type: 'date', 16 | colorKey: 'base0D', 17 | Renderer: ({ value }) => <>{value.toLocaleTimeString('en-us', displayOptions)} 18 | }) 19 | -------------------------------------------------------------------------------- /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 | return `${length} Items${name ? ` (${name})` : ''}` 30 | } 31 | 32 | const PreObjectType: FC> = (props) => { 33 | const metadataColor = useJsonViewerStore(store => store.colorspace.base04) 34 | const textColor = useTextColor() 35 | const isArray = useMemo(() => Array.isArray(props.value), [props.value]) 36 | const isEmptyValue = useMemo(() => getValueSize(props.value) === 0, [props.value]) 37 | const sizeOfValue = useMemo(() => inspectMetadata(props.value), [props.value]) 38 | const displaySize = useJsonViewerStore(store => store.displaySize) 39 | const shouldDisplaySize = useMemo(() => typeof displaySize === 'function' ? displaySize(props.path, props.value) : displaySize, [displaySize, props.path, props.value]) 40 | const isTrap = useIsCycleReference(props.path, props.value) 41 | return ( 42 | 49 | {isArray ? arrayLb : objectLb} 50 | {shouldDisplaySize && props.inspect && !isEmptyValue && ( 51 | 60 | {sizeOfValue} 61 | 62 | )} 63 | 64 | {isTrap && !props.inspect && ( 65 | <> 66 | 73 | {isTrap} 74 | 75 | )} 76 | 77 | ) 78 | } 79 | 80 | const PostObjectType: FC> = (props) => { 81 | const metadataColor = useJsonViewerStore(store => store.colorspace.base04) 82 | const isArray = useMemo(() => Array.isArray(props.value), [props.value]) 83 | const isEmptyValue = useMemo(() => getValueSize(props.value) === 0, [props.value]) 84 | const sizeOfValue = useMemo(() => inspectMetadata(props.value), [props.value]) 85 | const displaySize = useJsonViewerStore(store => store.displaySize) 86 | const shouldDisplaySize = useMemo(() => typeof displaySize === 'function' ? displaySize(props.path, props.value) : displaySize, [displaySize, props.path, props.value]) 87 | 88 | return ( 89 | 90 | {isArray ? arrayRb : objectRb} 91 | {shouldDisplaySize && (isEmptyValue || !props.inspect) 92 | ? ( 93 | 102 | {sizeOfValue} 103 | 104 | ) 105 | : null} 106 | 107 | ) 108 | } 109 | 110 | function getIterator (value: any): value is Iterable { 111 | return typeof value?.[Symbol.iterator] === 'function' 112 | } 113 | 114 | const ObjectType: FC> = (props) => { 115 | const keyColor = useTextColor() 116 | const borderColor = useJsonViewerStore(store => store.colorspace.base02) 117 | const groupArraysAfterLength = useJsonViewerStore(store => store.groupArraysAfterLength) 118 | const isTrap = useIsCycleReference(props.path, props.value) 119 | const [displayLength, setDisplayLength] = useState(useJsonViewerStore(store => store.maxDisplayLength)) 120 | const objectSortKeys = useJsonViewerStore(store => store.objectSortKeys) 121 | const elements = useMemo(() => { 122 | if (!props.inspect) { 123 | return null 124 | } 125 | const value: unknown[] | object = props.value 126 | const iterator = getIterator(value) 127 | // Array also has iterator, we skip it and treat it as an array as normal. 128 | if (iterator && !Array.isArray(value)) { 129 | const elements = [] 130 | if (value instanceof Map) { 131 | value.forEach((value, k) => { 132 | // fixme: key might be a object, array, or any value for the `Map` 133 | const key = k.toString() 134 | const path = [...props.path, key] 135 | elements.push( 136 | 143 | ) 144 | }) 145 | } else { 146 | // iterate with iterator func 147 | const iterator = value[Symbol.iterator]() 148 | let result = iterator.next() 149 | let count = 0 150 | while (!result.done) { 151 | elements.push( 152 | 159 | ) 160 | count++ 161 | result = iterator.next() 162 | } 163 | } 164 | return elements 165 | } 166 | if (Array.isArray(value)) { 167 | // unknown[] 168 | if (value.length <= groupArraysAfterLength) { 169 | const elements = value.slice(0, displayLength).map((value, index) => { 170 | const path = [...props.path, index] 171 | return ( 172 | 178 | ) 179 | }) 180 | if (value.length > displayLength) { 181 | const rest = value.length - displayLength 182 | elements.push( 183 | setDisplayLength(length => length * 2)} 194 | > 195 | hidden {rest} items… 196 | 197 | ) 198 | } 199 | return elements 200 | } 201 | 202 | const elements: unknown[][] = segmentArray(value, groupArraysAfterLength) 203 | const prevElements = Array.isArray(props.prevValue) ? segmentArray(props.prevValue, groupArraysAfterLength) : undefined 204 | 205 | return elements.map((list, index) => { 206 | const path = [...props.path] 207 | return ( 208 | 215 | ) 216 | }) 217 | } 218 | // object 219 | let entries: [key: string, value: unknown][] = Object.entries(value) 220 | if (objectSortKeys) { 221 | entries = objectSortKeys === true 222 | ? entries.sort(([a], [b]) => a.localeCompare(b)) 223 | : entries.sort(([a], [b]) => objectSortKeys(a, b)) 224 | } 225 | const elements = entries.slice(0, displayLength).map(([key, value]) => { 226 | const path = [...props.path, key] 227 | return ( 228 | 229 | ) 230 | }) 231 | if (entries.length > displayLength) { 232 | const rest = entries.length - displayLength 233 | elements.push( 234 | setDisplayLength(length => length * 2)} 245 | > 246 | hidden {rest} items… 247 | 248 | ) 249 | } 250 | return elements 251 | }, [ 252 | props.inspect, 253 | props.value, 254 | props.prevValue, 255 | props.path, 256 | groupArraysAfterLength, 257 | displayLength, 258 | keyColor, 259 | objectSortKeys 260 | ]) 261 | const marginLeft = props.inspect ? 0.6 : 0 262 | const width = useJsonViewerStore(store => store.indentWidth) 263 | const indentWidth = props.inspect ? width - marginLeft : width 264 | const isEmptyValue = useMemo(() => getValueSize(props.value) === 0, [props.value]) 265 | if (isEmptyValue) { 266 | return null 267 | } 268 | return ( 269 | 279 | { 280 | props.inspect 281 | ? elements 282 | : !isTrap && ( 283 | props.setInspect(true)} 287 | sx={{ 288 | '&:hover': { cursor: 'pointer' }, 289 | padding: 0.5, 290 | userSelect: 'none' 291 | }} 292 | > 293 | … 294 | 295 | ) 296 | } 297 | 298 | ) 299 | } 300 | 301 | export const objectType: DataType = { 302 | is: (value) => typeof value === 'object', 303 | Component: ObjectType, 304 | PreComponent: PreObjectType, 305 | PostComponent: PostObjectType 306 | } 307 | -------------------------------------------------------------------------------- /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 (hasRest) { 29 | setShowRest(value => !value) 30 | } 31 | }} 32 | > 33 | " 34 | {value} 35 | {hasRest && !showRest && ()} 36 | " 37 | 38 | ) 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /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 } 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 }) => { 68 | const color = useJsonViewerStore(store => store.colorspace[colorKey]) 69 | return ( 70 | >( 74 | (event) => { 75 | setValue(event.target.value) 76 | }, [setValue] 77 | ) 78 | } 79 | size='small' 80 | multiline 81 | sx={{ 82 | color, 83 | padding: 0.5, 84 | borderStyle: 'solid', 85 | borderColor: 'black', 86 | borderWidth: 1, 87 | fontSize: '0.8rem', 88 | fontFamily: 'monospace', 89 | display: 'inline-flex' 90 | }} 91 | /> 92 | ) 93 | } 94 | EasyTypeEditor.displayName = `easy-${type}-type-editor` 95 | 96 | return { 97 | is, 98 | serialize, 99 | deserialize, 100 | Component: EasyType, 101 | Editor: EasyTypeEditor 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /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 Check = 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z' 14 | const ChevronRight = 'M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' 15 | 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' 16 | 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' 17 | 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' 18 | 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' 19 | const ExpandMore = 'M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z' 20 | 21 | export const CheckIcon: FC = (props) => { 22 | return 23 | } 24 | 25 | export const ChevronRightIcon: FC = (props) => { 26 | return 27 | } 28 | 29 | export const CircularArrowsIcon: FC = (props) => { 30 | return 31 | } 32 | 33 | export const CloseIcon: FC = (props) => { 34 | return 35 | } 36 | 37 | export const ContentCopyIcon: FC = (props) => { 38 | return 39 | } 40 | 41 | export const EditIcon: FC = (props) => { 42 | return 43 | } 44 | 45 | export const ExpandMoreIcon: FC = (props) => { 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /src/components/mui/DataBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import type { ComponentProps, FC } from 'react' 3 | 4 | type DataBoxProps = ComponentProps 5 | 6 | export const DataBox: FC = props => ( 7 | 15 | ) 16 | -------------------------------------------------------------------------------- /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 { 12 | useJsonViewerStore 13 | } from '../stores/JsonViewerStore' 14 | import { useIsCycleReference } from './useIsCycleReference' 15 | 16 | export function useInspect (path: (string | number)[], value: any, nestedIndex?: number) { 17 | const depth = path.length 18 | const isTrap = useIsCycleReference(path, value) 19 | const getInspectCache = useJsonViewerStore(store => store.getInspectCache) 20 | const setInspectCache = useJsonViewerStore(store => store.setInspectCache) 21 | const defaultInspectDepth = useJsonViewerStore(store => store.defaultInspectDepth) 22 | useEffect(() => { 23 | const inspect = getInspectCache(path, nestedIndex) 24 | if (inspect !== undefined) { 25 | return 26 | } 27 | if (nestedIndex !== undefined) { 28 | setInspectCache(path, false, nestedIndex) 29 | } else { 30 | // do not inspect when it is a cycle reference, otherwise there will have a loop 31 | const inspect = isTrap 32 | ? false 33 | : depth < defaultInspectDepth 34 | setInspectCache(path, inspect) 35 | } 36 | }, [defaultInspectDepth, depth, getInspectCache, isTrap, nestedIndex, path, setInspectCache]) 37 | const [inspect, set] = useState(() => { 38 | const shouldInspect = getInspectCache(path, nestedIndex) 39 | if (shouldInspect !== undefined) { 40 | return shouldInspect 41 | } 42 | if (nestedIndex !== undefined) { 43 | return false 44 | } 45 | return isTrap 46 | ? false 47 | : depth < defaultInspectDepth 48 | }) 49 | const setInspect = useCallback>>((apply) => { 50 | set((oldState) => { 51 | const newState = typeof apply === 'boolean' ? apply : apply(oldState) 52 | setInspectCache(path, newState, nestedIndex) 53 | return newState 54 | }) 55 | }, [nestedIndex, path, setInspectCache]) 56 | return [inspect, setInspect] as const 57 | } 58 | -------------------------------------------------------------------------------- /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) => { 9 | setIsDark(e.matches) 10 | } 11 | setIsDark(window.matchMedia(query).matches) 12 | const queryMedia = window.matchMedia(query) 13 | queryMedia.addEventListener('change', listener) 14 | return () => queryMedia.removeEventListener('change', listener) 15 | }, []) 16 | return isDark 17 | } 18 | -------------------------------------------------------------------------------- /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('editable', props.editable) 54 | useSetIfNotUndefinedEffect('indentWidth', props.indentWidth) 55 | useSetIfNotUndefinedEffect('onChange', props.onChange) 56 | useSetIfNotUndefinedEffect('groupArraysAfterLength', props.groupArraysAfterLength) 57 | useSetIfNotUndefinedEffect('keyRenderer', props.keyRenderer) 58 | useSetIfNotUndefinedEffect('maxDisplayLength', props.maxDisplayLength) 59 | useSetIfNotUndefinedEffect('enableClipboard', props.enableClipboard) 60 | useSetIfNotUndefinedEffect('highlightUpdates', props.highlightUpdates) 61 | useSetIfNotUndefinedEffect('rootName', props.rootName) 62 | useSetIfNotUndefinedEffect('displayDataTypes', props.displayDataTypes) 63 | useSetIfNotUndefinedEffect('displaySize', props.displaySize) 64 | useSetIfNotUndefinedEffect('onCopy', props.onCopy) 65 | useSetIfNotUndefinedEffect('onSelect', props.onSelect) 66 | useEffect(() => { 67 | if (props.theme === 'light') { 68 | setState({ 69 | colorspace: lightColorspace 70 | }) 71 | } else if (props.theme === 'dark') { 72 | setState({ 73 | colorspace: darkColorspace 74 | }) 75 | } else if (typeof props.theme === 'object') { 76 | setState({ 77 | colorspace: props.theme 78 | }) 79 | } 80 | }, [setState, props.theme]) 81 | const themeCls = useMemo(() => { 82 | if (typeof props.theme === 'object') return 'json-viewer-theme-custom' 83 | return props.theme === 'dark' ? 'json-viewer-theme-dark' : 'json-viewer-theme-light' 84 | }, [props.theme]) 85 | const onceRef = useRef(true) 86 | const registerTypes = useTypeRegistryStore(store => store.registerTypes) 87 | if (onceRef.current) { 88 | const allTypes = props.valueTypes 89 | ? [...predefinedTypes, ...props.valueTypes] 90 | : [...predefinedTypes] 91 | registerTypes(allTypes) 92 | onceRef.current = false 93 | } 94 | useEffect(() => { 95 | const allTypes = props.valueTypes 96 | ? [...predefinedTypes, ...props.valueTypes] 97 | : [...predefinedTypes] 98 | registerTypes(allTypes) 99 | }, [props.valueTypes, registerTypes]) 100 | 101 | const value = useJsonViewerStore(store => store.value) 102 | const prevValue = useJsonViewerStore(store => store.prevValue) 103 | const setHover = useJsonViewerStore(store => store.setHover) 104 | const onMouseLeave = useCallback(() => setHover(null), [setHover]) 105 | return ( 106 | 118 | [], [])} 122 | /> 123 | 124 | ) 125 | } 126 | 127 | export const JsonViewer = function JsonViewer (props: JsonViewerProps): ReactElement { 128 | if (process.env.NODE_ENV !== 'production') { 129 | if ('displayObjectSize' in props) { 130 | console.error('`displayObjectSize` is deprecated. Use `displaySize` instead.\nSee https://viewer.textea.io/migration/migration-v3#raname-displayobjectsize-to-displaysize for more information.') 131 | } 132 | } 133 | const isAutoDarkTheme = useThemeDetector() 134 | const themeType = useMemo(() => props.theme === 'auto' 135 | ? (isAutoDarkTheme ? 'light' : 'dark') 136 | : props.theme ?? 'light', [isAutoDarkTheme, props.theme]) 137 | const theme = useMemo(() => { 138 | const backgroundColor = typeof themeType === 'object' 139 | ? themeType.base00 140 | : themeType === 'dark' 141 | ? darkColorspace.base00 142 | : lightColorspace.base00 143 | return createTheme({ 144 | components: { 145 | MuiPaper: { 146 | styleOverrides: { 147 | root: { 148 | backgroundColor 149 | } 150 | } 151 | } 152 | }, 153 | palette: { 154 | mode: themeType === 'dark' ? 'dark' : 'light', 155 | background: { 156 | default: backgroundColor 157 | } 158 | } 159 | }) 160 | }, [themeType]) 161 | const mixedProps = { ...props, theme: themeType } 162 | 163 | // eslint-disable-next-line react-hooks/exhaustive-deps 164 | const jsonViewerStore = useMemo(() => createJsonViewerStore(props), []) 165 | const typeRegistryStore = useMemo(() => createTypeRegistryStore(), []) 166 | 167 | return ( 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | ) 176 | } 177 | 178 | export * from './components/DataTypes' 179 | export * from './theme/base16' 180 | export * from './type' 181 | export { applyValue, createDataType, defineDataType, isCycleReference, safeStringify } from './utils' 182 | -------------------------------------------------------------------------------- /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 } from '../type' 16 | 17 | const DefaultKeyRenderer: JsonViewerKeyRenderer = () => null 18 | DefaultKeyRenderer.when = () => false 19 | 20 | export type JsonViewerState = { 21 | inspectCache: Record 22 | hoverPath: { path: Path; nestedIndex?: number } | null 23 | indentWidth: number 24 | groupArraysAfterLength: number 25 | enableClipboard: boolean 26 | highlightUpdates: boolean 27 | maxDisplayLength: number 28 | defaultInspectDepth: number 29 | collapseStringsAfterLength: number 30 | objectSortKeys: boolean | ((a: string, b: string) => number) 31 | quotesOnKeys: boolean 32 | colorspace: Colorspace 33 | editable: boolean | ((path: Path, currentValue: U) => boolean) 34 | displayDataTypes: boolean 35 | rootName: false | string 36 | prevValue: T | undefined 37 | value: T 38 | onChange: JsonViewerOnChange 39 | onCopy: JsonViewerOnCopy | undefined 40 | onSelect: JsonViewerOnSelect | undefined 41 | keyRenderer: JsonViewerKeyRenderer 42 | displaySize: boolean | ((path: Path, value: unknown) => boolean) 43 | 44 | getInspectCache: (path: Path, nestedIndex?: number) => boolean 45 | setInspectCache: ( 46 | path: Path, action: SetStateAction, nestedIndex?: number) => void 47 | setHover: (path: Path | null, nestedIndex?: number) => void 48 | } 49 | 50 | export const createJsonViewerStore = (props: JsonViewerProps) => { 51 | return create()((set, get) => ({ 52 | // provided by user 53 | enableClipboard: props.enableClipboard ?? true, 54 | highlightUpdates: props.highlightUpdates ?? false, 55 | indentWidth: props.indentWidth ?? 3, 56 | groupArraysAfterLength: props.groupArraysAfterLength ?? 100, 57 | collapseStringsAfterLength: 58 | (props.collapseStringsAfterLength === false) 59 | ? Number.MAX_VALUE 60 | : props.collapseStringsAfterLength ?? 50, 61 | maxDisplayLength: props.maxDisplayLength ?? 30, 62 | rootName: props.rootName ?? 'root', 63 | onChange: props.onChange ?? (() => {}), 64 | onCopy: props.onCopy ?? undefined, 65 | onSelect: props.onSelect ?? undefined, 66 | keyRenderer: props.keyRenderer ?? DefaultKeyRenderer, 67 | editable: props.editable ?? false, 68 | defaultInspectDepth: props.defaultInspectDepth ?? 5, 69 | objectSortKeys: props.objectSortKeys ?? false, 70 | quotesOnKeys: props.quotesOnKeys ?? true, 71 | displayDataTypes: props.displayDataTypes ?? true, 72 | // internal state 73 | inspectCache: {}, 74 | hoverPath: null, 75 | colorspace: lightColorspace, 76 | value: props.value, 77 | prevValue: undefined, 78 | displaySize: props.displaySize ?? true, 79 | 80 | getInspectCache: (path, nestedIndex) => { 81 | const target = nestedIndex !== undefined 82 | ? path.join('.') + 83 | `[${nestedIndex}]nt` 84 | : path.join('.') 85 | return get().inspectCache[target] 86 | }, 87 | setInspectCache: (path, action, nestedIndex) => { 88 | const target = nestedIndex !== undefined 89 | ? path.join('.') + 90 | `[${nestedIndex}]nt` 91 | : path.join('.') 92 | set(state => ({ 93 | inspectCache: { 94 | ...state.inspectCache, 95 | [target]: typeof action === 'function' 96 | ? action( 97 | state.inspectCache[target]) 98 | : action 99 | } 100 | })) 101 | }, 102 | setHover: (path, nestedIndex) => { 103 | set({ 104 | hoverPath: path 105 | ? ({ path, nestedIndex }) 106 | : null 107 | }) 108 | } 109 | })) 110 | } 111 | 112 | // @ts-expect-error we intentionally want to pass undefined to the context 113 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24509#issuecomment-382213106 114 | export const JsonViewerStoreContext = createContext>(undefined) 115 | 116 | export const JsonViewerProvider = JsonViewerStoreContext.Provider 117 | 118 | export const useJsonViewerStore = (selector: (state: JsonViewerState) => U, equalityFn?: (a: U, b: U) => boolean) => { 119 | const store = useContext(JsonViewerStoreContext) 120 | return useStore(store, selector, equalityFn) 121 | } 122 | -------------------------------------------------------------------------------- /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 | type TypeRegistryState = { 22 | registry: DataType[] 23 | 24 | registerTypes: (setState: SetStateAction[]>) => void 25 | } 26 | 27 | export const createTypeRegistryStore = () => { 28 | return createStore()((set) => ({ 29 | registry: [], 30 | 31 | registerTypes: (setState) => { 32 | set(state => ({ 33 | registry: 34 | typeof setState === 'function' 35 | ? setState(state.registry) 36 | : setState 37 | })) 38 | } 39 | })) 40 | } 41 | 42 | // @ts-expect-error we intentionally want to pass undefined to the context 43 | // See https://github.com/DefinitelyTyped/DefinitelyTyped/pull/24509#issuecomment-382213106 44 | export const TypeRegistryStoreContext = createContext>(undefined) 45 | 46 | export const TypeRegistryProvider = TypeRegistryStoreContext.Provider 47 | 48 | export const useTypeRegistryStore = (selector: (state: TypeRegistryState) => U, equalityFn?: (a: U, b: U) => boolean) => { 49 | const store = useContext(TypeRegistryStoreContext) 50 | return useStore(store, selector, equalityFn) 51 | } 52 | 53 | function matchTypeComponents ( 54 | value: Value, 55 | path: Path, 56 | registry: TypeRegistryState['registry'] 57 | ): DataType { 58 | let potential: DataType | undefined 59 | for (const T of registry) { 60 | if (T.is(value, path)) { 61 | potential = T 62 | } 63 | } 64 | if (potential === undefined) { 65 | if (typeof value === 'object') { 66 | return objectType as unknown as DataType 67 | } 68 | throw new Error(`No type matched for value: ${value}`) 69 | } 70 | return potential 71 | } 72 | 73 | export function useTypeComponents (value: unknown, path: Path) { 74 | const registry = useTypeRegistryStore(store => store.registry) 75 | return useMemo(() => matchTypeComponents(value, path, registry), [value, path, registry]) 76 | } 77 | 78 | function memorizeDataType (dataType: DataType): DataType { 79 | function compare (prevProps: Readonly>, nextProps: Readonly>) { 80 | return ( 81 | Object.is(prevProps.value, nextProps.value) && 82 | prevProps.inspect && nextProps.inspect && 83 | prevProps.path?.join('.') === nextProps.path?.join('.') 84 | ) 85 | } 86 | dataType.Component = memo(dataType.Component, compare) 87 | if (dataType.Editor) { 88 | dataType.Editor = memo(dataType.Editor, function compare (prevProps, nextProps) { 89 | return Object.is(prevProps.value, nextProps.value) 90 | }) 91 | } 92 | if (dataType.PreComponent) { 93 | dataType.PreComponent = memo(dataType.PreComponent, compare) 94 | } 95 | if (dataType.PostComponent) { 96 | dataType.PostComponent = memo(dataType.PostComponent, compare) 97 | } 98 | return dataType 99 | } 100 | 101 | export const predefinedTypes :DataType[] = [ 102 | memorizeDataType(booleanType), 103 | memorizeDataType(dateType), 104 | memorizeDataType(nullType), 105 | memorizeDataType(undefinedType), 106 | memorizeDataType(stringType), 107 | memorizeDataType(functionType), 108 | memorizeDataType(nanType), 109 | memorizeDataType(intType), 110 | memorizeDataType(floatType), 111 | memorizeDataType(bigIntType) 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 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 34 | */ 35 | export type JsonViewerOnSelect = ( 36 | path: Path, 37 | value: U, 38 | ) => void 39 | 40 | export interface DataItemProps { 41 | inspect: boolean 42 | setInspect: Dispatch> 43 | value: ValueType 44 | prevValue: ValueType | undefined 45 | path: Path 46 | } 47 | 48 | export type EditorProps = { 49 | value: ValueType 50 | setValue: Dispatch 51 | } 52 | 53 | /** 54 | * A data type definition, including methods for checking, serializing, and deserializing values, as well as components for rendering and editing values. 55 | * 56 | * @template ValueType The type of the value represented by this data type 57 | */ 58 | export type DataType = { 59 | /** 60 | * Determines whether a given value belongs to this data type. 61 | * 62 | * @param value The value to check 63 | * @param path The path to the value within the input data structure 64 | * @returns `true` if the value belongs to this data type, `false` otherwise 65 | */ 66 | is: (value: unknown, path: Path) => boolean 67 | /** 68 | * Convert the value of this data type to a string for editing 69 | */ 70 | serialize?: (value: ValueType) => string 71 | /** 72 | * Converts a string representation of a value back to a value of this data type. 73 | * 74 | * Throws an error if the input is invalid, in which case the editor will ignore the change. 75 | */ 76 | deserialize?: (value: string) => ValueType 77 | /** 78 | * The main component used to render a value of this data type. 79 | */ 80 | Component: ComponentType> 81 | /** 82 | * An optional custom editor component for editing values of this data type. 83 | * 84 | * You must also provide a `serialize` and `deserialize` function to enable this feature. 85 | */ 86 | Editor?: ComponentType> 87 | /** 88 | * An optional component to render before the value. 89 | * 90 | * In collapsed mode, it will still be rendered as a prefix. 91 | */ 92 | PreComponent?: ComponentType> 93 | /** 94 | * An optional component to render after the value. 95 | * 96 | * In collapsed mode, it will still be rendered as a suffix. 97 | */ 98 | PostComponent?: ComponentType> 99 | } 100 | 101 | export interface JsonViewerKeyRenderer extends FC { 102 | when (props: DataItemProps): boolean 103 | } 104 | 105 | export type JsonViewerTheme = 'light' | 'dark' | 'auto' | Colorspace 106 | 107 | export type JsonViewerProps = { 108 | /** 109 | * Any value, `object`, `Array`, primitive type, even `Map` or `Set`. 110 | */ 111 | value: T 112 | 113 | /** 114 | * Name of the root value 115 | * 116 | * @default "root" 117 | */ 118 | rootName?: false | string 119 | 120 | /** 121 | * Color theme. 122 | * 123 | * @default 'light' 124 | */ 125 | theme?: JsonViewerTheme 126 | className?: string 127 | style?: CSSProperties 128 | /** 129 | * [The `sx` prop](https://mui.com/system/getting-started/the-sx-prop/) lets you style elements inline, using values from the theme. 130 | * 131 | * @see https://mui.com/system/getting-started/the-sx-prop/ 132 | */ 133 | sx?: SxProps 134 | 135 | /** 136 | * Indent width for nested objects 137 | * 138 | * @default 3 139 | */ 140 | indentWidth?: number 141 | /** 142 | * Customize a key, if `keyRenderer.when` returns `true`. 143 | */ 144 | keyRenderer?: JsonViewerKeyRenderer 145 | /** 146 | * Customize the definition of data types. 147 | * 148 | * @see https://viewer.textea.io/how-to/data-types 149 | */ 150 | valueTypes?: DataType[] 151 | /** Callback when value changed. */ 152 | onChange?: JsonViewerOnChange 153 | /** 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. */ 154 | onCopy?: JsonViewerOnCopy 155 | /** Callback when value selected. */ 156 | onSelect?: JsonViewerOnSelect 157 | /** 158 | * Whether enable clipboard feature. 159 | * 160 | * @default true 161 | */ 162 | enableClipboard?: boolean 163 | /** 164 | * Whether this value can be edited. 165 | * 166 | * Provide a function to customize this behavior by returning a boolean based on the value and path. 167 | * 168 | * @default false 169 | */ 170 | editable?: boolean | ((path: Path, currentValue: U) => boolean) 171 | /** 172 | * Default inspect depth for nested objects. 173 | * _If the number is set too large, it could result in performance issues._ 174 | * 175 | * @default 5 176 | */ 177 | defaultInspectDepth?: number 178 | /** 179 | * Hide items after reaching the count. 180 | * `Array` and `Object` will be affected. 181 | * 182 | * _If the number is set too large, it could result in performance issues._ 183 | * 184 | * @default 30 185 | */ 186 | maxDisplayLength?: number 187 | /** 188 | * When an integer value is assigned, arrays will be displayed in groups by count of the value. 189 | * Groups are displayed with bracket notation and can be expanded and collapsed by clicking on the brackets. 190 | * 191 | * @default 100 192 | */ 193 | groupArraysAfterLength?: number 194 | /** 195 | * Cut off the string after reaching the count. 196 | * Collapsed strings are followed by an ellipsis. 197 | * 198 | * String content can be expanded and collapsed by clicking on the string value. 199 | * 200 | * @default 50 201 | */ 202 | collapseStringsAfterLength?: number | false 203 | 204 | /** 205 | * Whether sort keys through `String.prototype.localeCompare()` 206 | * 207 | * @default false 208 | */ 209 | objectSortKeys?: boolean | ((a: string, b: string) => number) 210 | 211 | /** 212 | * Whether add quotes on keys. 213 | * 214 | * @default true 215 | */ 216 | quotesOnKeys?: boolean 217 | 218 | /** 219 | * Whether display data type labels 220 | * 221 | * @default true 222 | */ 223 | displayDataTypes?: boolean 224 | 225 | /** 226 | * Whether display the size of `Object`, `Array`, `Map` and `Set`. 227 | * 228 | * Provide a function to customize this behavior by returning a boolean based on the value and path. 229 | * 230 | * @default true 231 | */ 232 | displaySize?: boolean | ((path: Path, value: unknown) => boolean) 233 | 234 | /** 235 | * Whether to highlight updates. 236 | * 237 | * @default false 238 | */ 239 | highlightUpdates?: boolean 240 | } 241 | -------------------------------------------------------------------------------- /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 | 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 | export function applyValue (input: any, path: (string | number)[], value: any) { 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) input = shallowCopy(input) 51 | 52 | const [key, ...restPath] = path 53 | if (key !== undefined) { 54 | if (key === '__proto__') { 55 | throw new TypeError('Modification of prototype is not allowed') 56 | } 57 | if (restPath.length > 0) { 58 | input[key] = applyValue(input[key], restPath, value) 59 | } else { 60 | input[key] = value 61 | } 62 | } 63 | return input 64 | } 65 | 66 | /** 67 | * @deprecated use `defineDataType` instead 68 | */ 69 | // case 1: you only render with a single component 70 | export function createDataType ( 71 | is: (value: unknown, path: Path) => boolean, 72 | Component: ComponentType> 73 | ): { 74 | is: (value: unknown, path: Path) => boolean 75 | Component: ComponentType> 76 | } 77 | /** 78 | * @deprecated use `defineDataType` instead 79 | */ 80 | // case 2: you only render with a single component with editor 81 | export function createDataType ( 82 | is: (value: unknown, path: Path) => boolean, 83 | Component: ComponentType>, 84 | Editor: ComponentType> 85 | ): { 86 | is: (value: unknown, path: Path) => boolean 87 | Component: ComponentType> 88 | Editor: ComponentType> 89 | } 90 | /** 91 | * @deprecated use `defineDataType` instead 92 | */ 93 | // case 3: you only render with a component with pre and post. 94 | export function createDataType ( 95 | is: (value: unknown, path: Path) => boolean, 96 | Component: ComponentType>, 97 | Editor: undefined, 98 | PreComponent: ComponentType>, 99 | PostComponent: ComponentType> 100 | ): { 101 | is: (value: unknown, path: Path) => boolean 102 | Component: ComponentType> 103 | PreComponent: ComponentType> 104 | PostComponent: ComponentType> 105 | } 106 | /** 107 | * @deprecated use `defineDataType` instead 108 | */ 109 | // case 4: need all of these 110 | export function createDataType ( 111 | is: (value: unknown, path: Path) => boolean, 112 | Component: ComponentType>, 113 | Editor: ComponentType>, 114 | PreComponent: ComponentType>, 115 | PostComponent: ComponentType> 116 | ): { 117 | is: (value: unknown, path: Path) => boolean 118 | Component: ComponentType> 119 | Editor: ComponentType> 120 | PreComponent: ComponentType> 121 | PostComponent: ComponentType> 122 | } 123 | /** 124 | * @deprecated use `defineDataType` instead 125 | */ 126 | export function createDataType ( 127 | is: (value: unknown, path: Path) => boolean, 128 | Component: ComponentType>, 129 | Editor?: ComponentType> | undefined, 130 | PreComponent?: ComponentType> | undefined, 131 | PostComponent?: ComponentType> | undefined 132 | ): any { 133 | if (process.env.NODE_ENV !== 'production') { 134 | console.warn('createDataType is deprecated, please use `defineDataType` instead. See https://viewer.textea.io/migration/migration-v3#use-definedatatype-instead-of-createdatatype for more information.') 135 | } 136 | return { 137 | is, 138 | Component, 139 | Editor, 140 | PreComponent, 141 | PostComponent 142 | } 143 | } 144 | 145 | /** 146 | * Define custom data types for any data structure 147 | */ 148 | export function defineDataType ({ 149 | is, 150 | serialize, 151 | deserialize, 152 | Component, 153 | Editor, 154 | PreComponent, 155 | PostComponent 156 | }: { 157 | /** 158 | * Determines whether a given value belongs to this data type. 159 | * 160 | * @param value The value to check 161 | * @param path The path to the value within the input data structure 162 | * @returns `true` if the value belongs to this data type, `false` otherwise 163 | */ 164 | is: (value: unknown, path: Path) => boolean 165 | /** 166 | * Convert the value of this data type to a string for editing 167 | */ 168 | serialize?: (value: ValueType) => string 169 | /** 170 | * Converts a string representation of a value back to a value of this data type. 171 | * 172 | * Throws an error if the input is invalid, in which case the editor will ignore the change. 173 | */ 174 | deserialize?: (value: string) => ValueType 175 | /** 176 | * The main component used to render a value of this data type. 177 | */ 178 | Component: ComponentType> 179 | /** 180 | * An optional custom editor component for editing values of this data type. 181 | * 182 | * You must also provide a `serialize` and `deserialize` function to enable this feature. 183 | */ 184 | Editor?: ComponentType> 185 | /** 186 | * An optional component to render before the value. 187 | * 188 | * In collapsed mode, it will still be rendered as a prefix. 189 | */ 190 | PreComponent?: ComponentType> 191 | /** 192 | * An optional component to render after the value. 193 | * 194 | * In collapsed mode, it will still be rendered as a suffix. 195 | */ 196 | PostComponent?: ComponentType> 197 | }): DataType { 198 | return { 199 | is, 200 | serialize, 201 | deserialize, 202 | Component, 203 | Editor, 204 | PreComponent, 205 | PostComponent 206 | } 207 | } 208 | 209 | export const isCycleReference = ( 210 | root: any, path: (string | number)[], value: unknown): false | string => { 211 | if (root === null || value === null) { 212 | return false 213 | } 214 | if (typeof root !== 'object') { 215 | return false 216 | } 217 | if (typeof value !== 'object') { 218 | return false 219 | } 220 | if (Object.is(root, value) && path.length !== 0) { 221 | return '' 222 | } 223 | const currentPath = [] 224 | const arr = [...path] 225 | let currentRoot = root 226 | while (currentRoot !== value || arr.length !== 0) { 227 | if (typeof currentRoot !== 'object' || currentRoot === null) { 228 | return false 229 | } 230 | if (Object.is(currentRoot, value)) { 231 | return currentPath.reduce((path, value, currentIndex) => { 232 | if (typeof value === 'number') { 233 | return path + `[${value}]` 234 | } 235 | return path + `${currentIndex === 0 ? '' : '.'}${value}` 236 | }, '') 237 | } 238 | const target = arr.shift()! 239 | currentPath.push(target) 240 | currentRoot = currentRoot[target] 241 | } 242 | return false 243 | } 244 | 245 | export function getValueSize (value: any): number { 246 | if (value === null || undefined) { 247 | return 0 248 | } else if (Array.isArray(value)) { 249 | return value.length 250 | } else if (value instanceof Map || value instanceof Set) { 251 | return value.size 252 | } else if (value instanceof Date) { 253 | return 1 254 | } else if (typeof value === 'object') { 255 | return Object.keys(value).length 256 | } else if (typeof value === 'string') { 257 | return value.length 258 | } 259 | return 1 260 | } 261 | 262 | export function segmentArray (arr: T[], size: number): T[][] { 263 | const result: T[][] = [] 264 | let index = 0 265 | while (index < arr.length) { 266 | result.push(arr.slice(index, index + size)) 267 | index += size 268 | } 269 | return result 270 | } 271 | 272 | /** 273 | * A safe version of `JSON.stringify` that handles circular references and BigInts. 274 | * 275 | * *This function might be changed in the future to support more types. Use it with caution.* 276 | * 277 | * @param obj A JavaScript value, usually an object or array, to be converted. 278 | * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. 279 | * @returns 280 | */ 281 | export function safeStringify (obj: any, space?: string | number) { 282 | const seenValues: any[] = [] 283 | 284 | function replacer (key: string | number, value: any) { 285 | // https://github.com/GoogleChromeLabs/jsbi/issues/30 286 | if (typeof value === 'bigint') return value.toString() 287 | 288 | // Map and Set are not supported by JSON.stringify 289 | if (value instanceof Map) { 290 | if ('toJSON' in value && typeof value.toJSON === 'function') return value.toJSON() 291 | if (value.size === 0) return {} 292 | 293 | if (seenValues.includes(value)) return '[Circular]' 294 | seenValues.push(value) 295 | 296 | const entries = Array.from(value.entries()) 297 | if (entries.every(([key]) => typeof key === 'string' || typeof key === 'number')) { 298 | return Object.fromEntries(entries) 299 | } 300 | 301 | // if keys are not string or number, we can't convert to object 302 | // fallback to default behavior 303 | return {} 304 | } 305 | if (value instanceof Set) { 306 | if ('toJSON' in value && typeof value.toJSON === 'function') return value.toJSON() 307 | 308 | if (seenValues.includes(value)) return '[Circular]' 309 | seenValues.push(value) 310 | 311 | return Array.from(value.values()) 312 | } 313 | 314 | // https://stackoverflow.com/a/72457899 315 | if (typeof value === 'object' && value !== null && Object.keys(value).length) { 316 | const stackSize = seenValues.length 317 | if (stackSize) { 318 | // clean up expired references 319 | for (let n = stackSize - 1; n >= 0 && seenValues[n][key] !== value; --n) { seenValues.pop() } 320 | if (seenValues.includes(value)) return '[Circular]' 321 | } 322 | seenValues.push(value) 323 | } 324 | return value 325 | } 326 | 327 | return JSON.stringify(obj, replacer, space) 328 | } 329 | 330 | export async function copyString (value: string) { 331 | if ('clipboard' in navigator) { 332 | try { 333 | await navigator.clipboard.writeText(value) 334 | } catch { 335 | // When navigator.clipboard throws an error, fallback to copy-to-clipboard package 336 | } 337 | } 338 | 339 | // fallback to copy-to-clipboard when navigator.clipboard is not available 340 | copyToClipboard(value) 341 | } 342 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react' 2 | import { expectTypeOf } from 'expect-type' 3 | import { describe, expect, it } from 'vitest' 4 | 5 | import { defineDataType, JsonViewer } from '../src' 6 | 7 | function aPlusB (a: number, b: number) { 8 | return a + b 9 | } 10 | 11 | const loopObject = { 12 | foo: 1, 13 | goo: 'string' 14 | } as Record 15 | 16 | loopObject.self = loopObject 17 | 18 | const loopArray = [ 19 | loopObject 20 | ] 21 | 22 | loopArray[1] = loopArray 23 | 24 | const longArray = Array.from({ length: 1000 }).map((_, i) => i) 25 | const map = new Map() 26 | map.set('foo', 1) 27 | map.set('goo', 'hello') 28 | map.set({}, 'world') 29 | 30 | const set = new Set([1, 2, 3]) 31 | 32 | const superLongString = '1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' 33 | 34 | const full = { 35 | loopObject, 36 | loopArray, 37 | longArray, 38 | string: 'this is a string', 39 | integer: 42, 40 | array: [19, 19, 810, 'test', NaN], 41 | nestedArray: [ 42 | [1, 2], 43 | [3, 4] 44 | ], 45 | map, 46 | set, 47 | float: 114.514, 48 | undefined, 49 | superLongString, 50 | object: { 51 | 'first-child': true, 52 | 'second-child': false, 53 | 'last-child': null 54 | }, 55 | fn: aPlusB, 56 | string_number: '1234', 57 | timer: 0, 58 | date: new Date('Tue Sep 13 2022 14:07:44 GMT-0500 (Central Daylight Time)'), 59 | bigint: 123456789087654321n 60 | } 61 | 62 | describe('render ', () => { 63 | it('render undefined', () => { 64 | render() 65 | }) 66 | 67 | it('render null', () => { 68 | render() 69 | }) 70 | 71 | it('render null', () => { 72 | render() 73 | }) 74 | 75 | it('render NaN', () => { 76 | render() 77 | }) 78 | 79 | it('render number', () => { 80 | render() 81 | render() 82 | render() 83 | render() 84 | render() 85 | render() 86 | }) 87 | 88 | it('render bigint', () => { 89 | render() 90 | }) 91 | 92 | it('render array', () => { 93 | render() 94 | render() 95 | }) 96 | 97 | it('render Set', () => { 98 | render() 99 | }) 100 | 101 | it('render Map', () => { 102 | render( 103 | ([['foo', 1], ['goo', 2]])} 105 | /> 106 | ) 107 | render( 108 | ([[[], 1], [{}, 2]])} 110 | /> 111 | ) 112 | }) 113 | 114 | it('render object', () => { 115 | render() 116 | render( 117 | 126 | ) 127 | }) 128 | 129 | it('render function', () => { 130 | render( 131 | 136 | ) 137 | render( a + b} />) 138 | }) 139 | 140 | it('render full', () => { 141 | render() 142 | }) 143 | }) 144 | describe('render with multiple instances', () => { 145 | it('render', () => { 146 | const { container } = render( 147 | <> 148 | true, 154 | Component: () => { 155 | return <>first viewer 156 | } 157 | } 158 | ]} 159 | /> 160 | true, 166 | Component: () => { 167 | return <>second viewer 168 | } 169 | } 170 | ]} 171 | /> 172 | 173 | ) 174 | expect(container.children.length).eq(2) 175 | expect(container.children.item(0)!.textContent).eq('first viewer') 176 | expect(container.children.item(1)!.textContent).eq('second viewer') 177 | }) 178 | }) 179 | 180 | describe('render with props', () => { 181 | it('render with quotesOnKeys', () => { 182 | const selection = [true, false] 183 | selection.forEach(quotesOnKeys => { 184 | render() 185 | }) 186 | }) 187 | 188 | it('render with theme', () => { 189 | const selection = [ 190 | 'light', 191 | 'dark', 192 | { 193 | scheme: 'Ocean', 194 | author: 'Chris Kempson (http://chriskempson.com)', 195 | base00: '#2b303b', 196 | base01: '#343d46', 197 | base02: '#4f5b66', 198 | base03: '#65737e', 199 | base04: '#a7adba', 200 | base05: '#c0c5ce', 201 | base06: '#dfe1e8', 202 | base07: '#eff1f5', 203 | base08: '#bf616a', 204 | base09: '#d08770', 205 | base0A: '#ebcb8b', 206 | base0B: '#a3be8c', 207 | base0C: '#96b5b4', 208 | base0D: '#8fa1b3', 209 | base0E: '#b48ead', 210 | base0F: '#ab7967' 211 | } 212 | ] as const 213 | selection.forEach(theme => { 214 | render() 215 | }) 216 | }) 217 | 218 | it('render with objectSortKeys', () => { 219 | const selection = [ 220 | true, 221 | false, 222 | (a: string, b: string) => a.localeCompare(b)] 223 | selection.forEach(objectSortKeys => { 224 | render() 225 | }) 226 | }) 227 | 228 | it('render with rootName false', async () => { 229 | render() 230 | expect((await screen.findByTestId('data-key-pair')).innerText) 231 | .toEqual(undefined) 232 | }) 233 | 234 | it('render with displayDataTypes', async () => { 235 | const selection = [true, false] 236 | selection.forEach(displayDataTypes => { 237 | render() 238 | }) 239 | }) 240 | 241 | it('render with displayObjectSize', async () => { 242 | const selection = [true, false] 243 | selection.forEach(displayObjectSize => { 244 | render() 245 | }) 246 | }) 247 | 248 | it('render with dataTypes', async () => { 249 | render() 250 | render( 251 | typeof value === 'string', 256 | Component: (props) => { 257 | expectTypeOf(props.value).toMatchTypeOf() 258 | return null 259 | } 260 | }, 261 | defineDataType({ 262 | is: (value) => typeof value === 'string', 263 | Component: (props) => { 264 | expectTypeOf(props.value).toMatchTypeOf() 265 | return null 266 | } 267 | }) 268 | ]} 269 | /> 270 | ) 271 | }) 272 | }) 273 | 274 | describe('Expand elements by click on dots', () => { 275 | it('render', () => { 276 | const { container, rerender } = render( 277 | 282 | ) 283 | 284 | let elements = container.getElementsByClassName('data-object-body') 285 | expect(elements.length).eq(1) 286 | expect(elements[0].textContent).eq('…') 287 | fireEvent.click(elements[0]) 288 | 289 | rerender( 290 | 295 | ) 296 | elements = container.getElementsByClassName('data-object-body') 297 | expect(elements.length).eq(0) 298 | 299 | elements = container.getElementsByClassName('data-object') 300 | expect(elements.length).eq(1) 301 | expect(elements[0].children.length).eq(2) 302 | }) 303 | }) 304 | 305 | describe('test functions', () => { 306 | const func1 = function (...args: any[]) { 307 | console.log(args) 308 | return '111' 309 | } 310 | 311 | function func2 (...args: any[]) { 312 | console.log(args) 313 | return '222' 314 | } 315 | 316 | const dataProvider = [ 317 | [ 318 | function (...args: any) { 319 | console.log(args) 320 | return '333' 321 | }, 322 | '(...args) {', 323 | ` 324 | console.log(args); 325 | return "333"; 326 | ` 327 | ], 328 | [ 329 | func1, 330 | '(...args) {', 331 | ` 332 | console.log(args); 333 | return "111"; 334 | ` 335 | ], 336 | [ 337 | func2, 338 | 'func2(...args) {', 339 | ` 340 | console.log(args); 341 | return "222"; 342 | ` 343 | ], 344 | [ 345 | // eslint-disable-next-line unused-imports/no-unused-vars 346 | (...args: any) => console.log('555'), 347 | '(...args) => {', 348 | ' console.log("555")' 349 | ], 350 | [ 351 | (...args: any) => { 352 | console.log(args) 353 | return '666' 354 | }, 355 | '(...args) => {', 356 | ` { 357 | console.log(args); 358 | return "666"; 359 | }` 360 | ], 361 | [ 362 | // eslint-disable-next-line unused-imports/no-unused-vars 363 | function (a: number, b: number) { 364 | throw Error('Be careful to use the function just as value in useState() hook') 365 | }, 366 | '(a, b) {', 367 | ` 368 | throw Error("Be careful to use the function just as value in useState() hook"); 369 | ` 370 | ], 371 | [ 372 | ({ prop1, prop2, ...other }: any) => { 373 | console.log(prop1, prop2, other) 374 | return '777' 375 | }, 376 | '({ prop1, prop2, ...other }) => {', 377 | ` { 378 | console.log(prop1, prop2, other); 379 | return "777"; 380 | }` 381 | ], 382 | [ 383 | { 384 | func: ({ prop1, prop2, ...other }: any) => { 385 | console.log(prop1, prop2, other) 386 | return '888' 387 | } 388 | }, 389 | '({ prop1, prop2, ...other }) => {', 390 | ` { 391 | console.log(prop1, prop2, other); 392 | return "888"; 393 | }` 394 | ], 395 | [ 396 | // @ts-ignore 397 | function (e, n) { return e + n }, 398 | '(e, n) {', 399 | ` 400 | return e + n; 401 | ` 402 | ] 403 | ] 404 | for (const iteration of dataProvider) { 405 | it('render', () => { 406 | const { container } = render( 407 | 411 | ) 412 | expect(container.children.length).eq(1) 413 | const functionName = container.getElementsByClassName('data-function-start') 414 | expect(functionName.length).eq(1) 415 | expect(functionName[0].textContent).eq(iteration[1]) 416 | 417 | const functionBody = container.getElementsByClassName('data-function') 418 | expect(functionBody.length).eq(1) 419 | expect(functionBody[0].textContent).eq(iteration[2]) 420 | }) 421 | } 422 | }) 423 | 424 | describe('Expand function by click on dots', () => { 425 | it('render', () => { 426 | const { container, rerender } = render( 427 | console.log(e)} 430 | defaultInspectDepth={0} 431 | /> 432 | ) 433 | 434 | let elements = container.getElementsByClassName('data-function-body') 435 | expect(elements.length).eq(1) 436 | expect(elements[0].textContent).eq('…') 437 | fireEvent.click(elements[0]) 438 | 439 | rerender( 440 | console.log(e)} 443 | defaultInspectDepth={0} 444 | /> 445 | ) 446 | elements = container.getElementsByClassName('data-function-body') 447 | expect(elements.length).eq(0) 448 | 449 | elements = container.getElementsByClassName('data-function') 450 | expect(elements.length).eq(1) 451 | expect(elements[0].children.length).eq(0) 452 | expect(elements[0].textContent).not.eq('…') 453 | }) 454 | }) 455 | 456 | describe('See empty iterables', () => { 457 | it('Array', () => { 458 | const { container } = render( 459 | 464 | ) 465 | 466 | let elements = container.getElementsByClassName('data-object-body') 467 | expect(elements.length).eq(0) 468 | elements = container.getElementsByClassName('data-object-start') 469 | expect(elements.length).eq(1) 470 | elements = container.getElementsByClassName('data-object-end') 471 | expect(elements.length).eq(1) 472 | }) 473 | it('Object', () => { 474 | const { container } = render( 475 | 480 | ) 481 | 482 | let elements = container.getElementsByClassName('data-object-body') 483 | expect(elements.length).eq(0) 484 | elements = container.getElementsByClassName('data-object-start') 485 | expect(elements.length).eq(1) 486 | elements = container.getElementsByClassName('data-object-end') 487 | expect(elements.length).eq(1) 488 | }) 489 | it('Map', () => { 490 | const { container } = render( 491 | 496 | ) 497 | 498 | let elements = container.getElementsByClassName('data-object-body') 499 | expect(elements.length).eq(0) 500 | elements = container.getElementsByClassName('data-object-start') 501 | expect(elements.length).eq(1) 502 | elements = container.getElementsByClassName('data-object-end') 503 | expect(elements.length).eq(1) 504 | }) 505 | it('Set', () => { 506 | const { container } = render( 507 | 512 | ) 513 | 514 | let elements = container.getElementsByClassName('data-object-body') 515 | expect(elements.length).eq(0) 516 | elements = container.getElementsByClassName('data-object-start') 517 | expect(elements.length).eq(1) 518 | elements = container.getElementsByClassName('data-object-end') 519 | expect(elements.length).eq(1) 520 | }) 521 | }) 522 | 523 | describe('Click on empty iterables', () => { 524 | it('Array', () => { 525 | const Component = () => ( 526 | 531 | ) 532 | const { container, rerender } = render() 533 | 534 | // Click on start brace 535 | let elements = container.getElementsByClassName('data-object-start') 536 | expect(elements.length).eq(1) 537 | fireEvent.click(elements[0]) 538 | 539 | rerender() 540 | elements = container.getElementsByClassName('data-object-body') 541 | expect(elements.length).eq(0) 542 | 543 | // Click on end brace 544 | elements = container.getElementsByClassName('data-object-end') 545 | expect(elements.length).eq(1) 546 | fireEvent.click(elements[0]) 547 | 548 | rerender() 549 | elements = container.getElementsByClassName('data-object-body') 550 | expect(elements.length).eq(0) 551 | }) 552 | it('Object', () => { 553 | const Component = () => ( 554 | 559 | ) 560 | const { container, rerender } = render() 561 | 562 | // Click on start brace 563 | let elements = container.getElementsByClassName('data-object-start') 564 | expect(elements.length).eq(1) 565 | fireEvent.click(elements[0]) 566 | 567 | rerender() 568 | elements = container.getElementsByClassName('data-object-body') 569 | expect(elements.length).eq(0) 570 | 571 | // Click on end brace 572 | elements = container.getElementsByClassName('data-object-end') 573 | expect(elements.length).eq(1) 574 | fireEvent.click(elements[0]) 575 | 576 | rerender() 577 | elements = container.getElementsByClassName('data-object-body') 578 | expect(elements.length).eq(0) 579 | }) 580 | it('Map', () => { 581 | const Component = () => ( 582 | 587 | ) 588 | const { container, rerender } = render() 589 | 590 | // Click on start brace 591 | let elements = container.getElementsByClassName('data-object-start') 592 | expect(elements.length).eq(1) 593 | fireEvent.click(elements[0]) 594 | 595 | rerender() 596 | elements = container.getElementsByClassName('data-object-body') 597 | expect(elements.length).eq(0) 598 | 599 | // Click on end brace 600 | elements = container.getElementsByClassName('data-object-end') 601 | expect(elements.length).eq(1) 602 | fireEvent.click(elements[0]) 603 | 604 | rerender() 605 | elements = container.getElementsByClassName('data-object-body') 606 | expect(elements.length).eq(0) 607 | }) 608 | it('Set', () => { 609 | const Component = () => ( 610 | 615 | ) 616 | const { container, rerender } = render() 617 | 618 | // Click on start brace 619 | let elements = container.getElementsByClassName('data-object-start') 620 | expect(elements.length).eq(1) 621 | fireEvent.click(elements[0]) 622 | 623 | rerender() 624 | elements = container.getElementsByClassName('data-object-body') 625 | expect(elements.length).eq(0) 626 | 627 | // Click on end brace 628 | elements = container.getElementsByClassName('data-object-end') 629 | expect(elements.length).eq(1) 630 | fireEvent.click(elements[0]) 631 | 632 | rerender() 633 | elements = container.getElementsByClassName('data-object-body') 634 | expect(elements.length).eq(0) 635 | }) 636 | }) 637 | 638 | describe('Show three dots after string collapsing', () => { 639 | it('render', () => { 640 | const Component = () => ( 641 | 646 | ) 647 | const { container, rerender } = render() 648 | 649 | let elements = container.getElementsByClassName('string-value') 650 | expect(elements.length).eq(1) 651 | expect(elements[0].children.length).eq(1) 652 | expect(elements[0].textContent).eq('"string…"') 653 | fireEvent.click(elements[0].children[0]) 654 | 655 | rerender() 656 | elements = container.getElementsByClassName('string-value') 657 | expect(elements.length).eq(1) 658 | expect(elements[0].children.length).eq(1) 659 | expect(elements[0].textContent).eq('"string string string"') 660 | fireEvent.click(elements[0].children[0]) 661 | 662 | rerender() 663 | elements = container.getElementsByClassName('string-value') 664 | expect(elements.length).eq(1) 665 | expect(elements[0].children.length).eq(1) 666 | expect(elements[0].textContent).eq('"string…"') 667 | }) 668 | }) 669 | -------------------------------------------------------------------------------- /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 { expectTypeOf } from 'expect-type' 2 | import type { ComponentType } from 'react' 3 | import { describe, expect, test } from 'vitest' 4 | 5 | import type { DataItemProps, Path } from '../src' 6 | import { applyValue, createDataType, isCycleReference } from '../src' 7 | import { safeStringify, segmentArray } from '../src/utils' 8 | 9 | describe('function applyValue', () => { 10 | const patches: any[] = [{}, undefined, 1, '2', 3n, 0.4] 11 | test('incorrect arguments', () => { 12 | expect(() => { 13 | applyValue({}, ['not', 'exist'], 1) 14 | }).toThrow() 15 | expect(() => { 16 | applyValue(1, ['not', 'exist'], 1) 17 | }).toThrow() 18 | }) 19 | 20 | test('undefined', () => { 21 | patches.forEach(patch => { 22 | const newValue = applyValue(undefined, [], patch) 23 | expect(newValue).is.eq(patch) 24 | }) 25 | }) 26 | 27 | test('null', () => { 28 | patches.forEach(patch => { 29 | const newValue = applyValue(null, [], patch) 30 | expect(newValue).is.eq(patch) 31 | }) 32 | }) 33 | 34 | test('number', () => { 35 | patches.forEach(patch => { 36 | const newValue = applyValue(1, [], patch) 37 | expect(newValue).is.eq(patch) 38 | }) 39 | patches.forEach(patch => { 40 | const newValue = applyValue(114514, [], patch) 41 | expect(newValue).is.eq(patch) 42 | }) 43 | }) 44 | 45 | test('string', () => { 46 | patches.forEach(patch => { 47 | const newValue = applyValue('', [], patch) 48 | expect(newValue).is.eq(patch) 49 | }) 50 | }) 51 | 52 | test('object', () => { 53 | const original = { 54 | foo: 1 55 | } 56 | const newValue = applyValue(original, ['foo'], 2) 57 | expect(newValue).is.deep.eq({ 58 | foo: 2 59 | }) 60 | }) 61 | 62 | test('object nested', () => { 63 | const original = { 64 | foo: { 65 | bar: { 66 | baz: 1 67 | } 68 | } 69 | } 70 | const newValue = applyValue(original, ['foo', 'bar', 'baz'], 2) 71 | expect(newValue).is.deep.eq({ 72 | foo: { 73 | bar: { 74 | baz: 2 75 | } 76 | } 77 | }) 78 | }) 79 | 80 | test('array', () => { 81 | const original = [1, 2, 3] 82 | const newValue = applyValue(original, [1], 4) 83 | expect(newValue).is.deep.eq([1, 4, 3]) 84 | }) 85 | 86 | test('array nested', () => { 87 | const original = [1, [2, [3, 4]]] 88 | const newValue = applyValue(original, [1, 1, 1], 5) 89 | expect(newValue).is.deep.eq([1, [2, [3, 5]]]) 90 | }) 91 | }) 92 | 93 | describe('function isCycleReference', () => { 94 | test('root is leaf', () => { 95 | const root = { 96 | leaf: {} 97 | } 98 | root.leaf = root 99 | expect(isCycleReference(root, ['leaf'], root.leaf)).to.eq('') 100 | }) 101 | 102 | test('branch is leaf', () => { 103 | const root = { 104 | a: { 105 | b: { 106 | c: {} 107 | } 108 | } 109 | } 110 | root.a.b.c = root.a.b 111 | expect(isCycleReference(root, ['a', 'b', 'c'], root.a.b.c)).to.eq('a.b') 112 | }) 113 | }) 114 | 115 | describe('function createDataType', () => { 116 | test('case 1', () => { 117 | const dataType = createDataType( 118 | (value) => { 119 | expectTypeOf(value).toBeUnknown() 120 | return true 121 | }, 122 | (props) => { 123 | expectTypeOf(props.value).toBeString() 124 | return null 125 | } 126 | ) 127 | expectTypeOf(dataType).toEqualTypeOf<{ 128 | is:(value: unknown, path: Path) => value is string 129 | Component: ComponentType> 130 | }>() 131 | expectTypeOf(dataType.is).returns.toBeBoolean() 132 | expect(dataType.is).toBeTypeOf('function') 133 | expect(dataType.Component).toBeTypeOf('function') 134 | }) 135 | test('case 2', () => { 136 | const dataType = createDataType( 137 | (value) => { 138 | expectTypeOf(value).toBeUnknown() 139 | return true 140 | }, 141 | (props) => { 142 | expectTypeOf(props.value).toBeString() 143 | return null 144 | }, 145 | (props) => { 146 | expectTypeOf(props.value).toBeString() 147 | return null 148 | } 149 | ) 150 | expectTypeOf(dataType).toEqualTypeOf<{ 151 | is:(value: unknown, path: Path) => value is string 152 | Component: ComponentType> 153 | Editor: ComponentType> 154 | }>() 155 | expectTypeOf(dataType.is).returns.toBeBoolean() 156 | expect(dataType.is).toBeTypeOf('function') 157 | expect(dataType.Component).toBeTypeOf('function') 158 | expect(dataType.Editor).toBeTypeOf('function') 159 | }) 160 | test('case 3', () => { 161 | const dataType = createDataType( 162 | (value) => { 163 | expectTypeOf(value).toBeUnknown() 164 | return true 165 | }, 166 | (props) => { 167 | expectTypeOf(props.value).toBeString() 168 | return null 169 | }, 170 | undefined, 171 | (props) => { 172 | expectTypeOf(props.value).toBeString() 173 | return null 174 | }, 175 | (props) => { 176 | expectTypeOf(props.value).toBeString() 177 | return null 178 | } 179 | ) 180 | expectTypeOf(dataType).toEqualTypeOf<{ 181 | is:(value: unknown, path: Path) => value is string 182 | Component: ComponentType> 183 | PreComponent: ComponentType> 184 | PostComponent: ComponentType> 185 | }>() 186 | expectTypeOf(dataType.is).returns.toBeBoolean() 187 | expect(dataType.is).toBeTypeOf('function') 188 | expect(dataType.Component).toBeTypeOf('function') 189 | expect(dataType.PreComponent).toBeTypeOf('function') 190 | expect(dataType.PostComponent).toBeTypeOf('function') 191 | }) 192 | 193 | test('case 4', () => { 194 | const dataType = createDataType( 195 | (value) => { 196 | expectTypeOf(value).toBeUnknown() 197 | return true 198 | }, 199 | (props) => { 200 | expectTypeOf(props.value).toBeString() 201 | return null 202 | }, 203 | (props) => { 204 | expectTypeOf(props.value).toBeString() 205 | return null 206 | }, 207 | (props) => { 208 | expectTypeOf(props.value).toBeString() 209 | return null 210 | }, 211 | (props) => { 212 | expectTypeOf(props.value).toBeString() 213 | return null 214 | } 215 | ) 216 | expectTypeOf(dataType).toEqualTypeOf<{ 217 | is:(value: unknown, path: Path) => value is string 218 | Component: ComponentType> 219 | Editor: ComponentType> 220 | PreComponent: ComponentType> 221 | PostComponent: ComponentType> 222 | }>() 223 | expectTypeOf(dataType.is).returns.toBeBoolean() 224 | expect(dataType.is).toBeTypeOf('function') 225 | expect(dataType.Component).toBeTypeOf('function') 226 | expect(dataType.Editor).toBeTypeOf('function') 227 | expect(dataType.PreComponent).toBeTypeOf('function') 228 | expect(dataType.PostComponent).toBeTypeOf('function') 229 | }) 230 | }) 231 | 232 | describe('function segmentArray', () => { 233 | test('case 1', () => { 234 | const array = [1, 2, 3, 4, 5] 235 | const result = segmentArray(array, 2) 236 | expect(result).to.deep.eq([ 237 | [1, 2], 238 | [3, 4], 239 | [5] 240 | ]) 241 | }) 242 | 243 | test('case 2', () => { 244 | const array = [1, 2, 3, 4, 5] 245 | const result = segmentArray(array, 3) 246 | expect(result).to.deep.eq([ 247 | [1, 2, 3], 248 | [4, 5] 249 | ]) 250 | }) 251 | 252 | test('case 3', () => { 253 | const array = [1, 2, 3, 4, 5] 254 | const result = segmentArray(array, 5) 255 | expect(result).to.deep.eq([ 256 | [1, 2, 3, 4, 5] 257 | ]) 258 | }) 259 | 260 | test('case 4', () => { 261 | const array = [1, 2, 3, 4, 5] 262 | const result = segmentArray(array, 6) 263 | expect(result).to.deep.eq([ 264 | [1, 2, 3, 4, 5] 265 | ]) 266 | }) 267 | }) 268 | 269 | describe('function circularStringify', () => { 270 | test('should works as JSON.stringify', () => { 271 | const obj = { foo: 1, bar: 2 } 272 | expect(safeStringify(obj)).to.eq(JSON.stringify(obj)) 273 | }) 274 | 275 | test('should works with circular reference in object', () => { 276 | const obj = { 277 | foo: 1, 278 | bar: { 279 | foo: 2, 280 | bar: null 281 | } 282 | } 283 | obj.bar.bar = obj.bar 284 | expect(safeStringify(obj)).to.eq('{"foo":1,"bar":{"foo":2,"bar":"[Circular]"}}') 285 | }) 286 | 287 | test('should works with circular reference in array', () => { 288 | const array = [1, 2, 3, 4, 5] 289 | // @ts-expect-error ignore 290 | array[2] = array 291 | expect(safeStringify(array)).to.eq('[1,2,"[Circular]",4,5]') 292 | }) 293 | 294 | test('should works with complex circular object', () => { 295 | const obj = { 296 | a: { 297 | b: { 298 | c: 1, 299 | d: 2 300 | } 301 | }, 302 | e: { 303 | f: 3, 304 | g: 4 305 | } 306 | } 307 | // @ts-expect-error ignore 308 | obj.a.b.e = obj.e 309 | // @ts-expect-error ignore 310 | obj.e.g = obj.a.b 311 | expect(safeStringify(obj)).to.eq('{"a":{"b":{"c":1,"d":2,"e":{"f":3,"g":"[Circular]"}}},"e":{"f":3,"g":"[Circular]"}}') 312 | }) 313 | 314 | test('should works with ES6 Map', () => { 315 | const map = new Map() 316 | map.set('foo', 1) 317 | map.set('bar', 2) 318 | expect(safeStringify(map)).to.eq('{"foo":1,"bar":2}') 319 | }) 320 | 321 | test('should works with ES6 Set', () => { 322 | const set = new Set() 323 | set.add(1) 324 | set.add(2) 325 | expect(safeStringify(set)).to.eq('[1,2]') 326 | }) 327 | 328 | test('should works with ES6 Map with circular reference', () => { 329 | const map = new Map() 330 | map.set('foo', 1) 331 | map.set('bar', map) 332 | expect(safeStringify(map)).to.eq('{"foo":1,"bar":"[Circular]"}') 333 | }) 334 | 335 | test('should works with ES6 Set with circular reference', () => { 336 | const set = new Set() 337 | set.add(1) 338 | set.add(set) 339 | expect(safeStringify(set)).to.eq('[1,"[Circular]"]') 340 | }) 341 | }) 342 | -------------------------------------------------------------------------------- /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 | } 11 | }) 12 | --------------------------------------------------------------------------------