├── .eslintignore ├── .eslintrc-md.json ├── .eslintrc.json ├── .github └── workflows │ ├── build-lint-test.yml │ ├── build.yml │ ├── check-pr.yml │ ├── new_issues.yml │ ├── promote.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── jest.setup.js ├── module.js ├── package.json ├── packages ├── module │ ├── CHANGELOG.md │ ├── package.json │ ├── patternfly-a11y.config.js │ ├── patternfly-docs │ │ ├── content │ │ │ └── extensions │ │ │ │ └── react-log-viewer │ │ │ │ ├── demos │ │ │ │ ├── ComplexToolbarLogViewer.jsx │ │ │ │ └── LogViewer.md │ │ │ │ ├── design-guidelines │ │ │ │ ├── design-guidelines.md │ │ │ │ └── img │ │ │ │ │ ├── logviewer.png │ │ │ │ │ ├── logviewerclear.png │ │ │ │ │ ├── logviewercog.png │ │ │ │ │ ├── logviewerdark.png │ │ │ │ │ └── logviewersearch.png │ │ │ │ └── examples │ │ │ │ ├── ANSIColorLogViewer.jsx │ │ │ │ ├── BasicLogViewer.jsx │ │ │ │ ├── BasicSearchLogViewer.jsx │ │ │ │ ├── CustomControlLogViewer.jsx │ │ │ │ ├── FooterComponentLogViewer.jsx │ │ │ │ ├── HeaderComponentLogViewer.jsx │ │ │ │ ├── basic.md │ │ │ │ └── realTestData.js │ │ ├── generated │ │ │ ├── extensions │ │ │ │ ├── log-viewer │ │ │ │ │ ├── design-guidelines.js │ │ │ │ │ ├── extensions.js │ │ │ │ │ ├── react-demos.js │ │ │ │ │ └── react.js │ │ │ │ └── react-log-viewer │ │ │ │ │ ├── design-guidelines.js │ │ │ │ │ └── react.js │ │ │ └── index.js │ │ ├── pages │ │ │ └── index.js │ │ ├── patternfly-docs.config.js │ │ ├── patternfly-docs.css.js │ │ ├── patternfly-docs.routes.js │ │ └── patternfly-docs.source.js │ ├── release.config.js │ ├── scripts │ │ ├── generateClassMaps.js │ │ └── writeClassMaps.js │ ├── src │ │ ├── LogViewer │ │ │ ├── LogViewer.tsx │ │ │ ├── LogViewerContext.tsx │ │ │ ├── LogViewerRow.tsx │ │ │ ├── LogViewerSearch.tsx │ │ │ ├── __test__ │ │ │ │ ├── Logviewer.test.tsx │ │ │ │ └── realTestData.ts │ │ │ ├── css │ │ │ │ ├── log-viewer.css │ │ │ │ └── log-viewer.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ │ ├── constants.ts │ │ │ │ └── utils.tsx │ │ ├── ansi_up │ │ │ └── ansi_up.ts │ │ ├── index.ts │ │ └── react-window │ │ │ ├── VariableSizeList.tsx │ │ │ ├── areEqual.ts │ │ │ ├── createListComponent.ts │ │ │ ├── index.ts │ │ │ ├── shallowDiffers.ts │ │ │ └── timer.ts │ ├── tsconfig.cjs.json │ └── tsconfig.json └── transformer-cjs-imports │ ├── CHANGELOG.md │ ├── index.js │ └── package.json ├── renovate.json ├── styleMock.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # Javascript builds 2 | node_modules 3 | dist 4 | tsc_out 5 | .out 6 | .changelog 7 | .DS_Store 8 | coverage 9 | .cache 10 | .tmp 11 | .eslintcache 12 | generated 13 | 14 | # package managers 15 | yarn-error.log 16 | lerna-debug.log 17 | 18 | # IDEs and editors 19 | .idea 20 | .project 21 | .classpath 22 | .c9 23 | *.launch 24 | .settings 25 | *.sublime-workspace 26 | .history 27 | .vscode 28 | .yo-rc.json 29 | 30 | # IDE - VSCode 31 | .vscode 32 | # For vim 33 | *.swp 34 | 35 | public -------------------------------------------------------------------------------- /.eslintrc-md.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "markdown", 4 | "react" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 9, 8 | "sourceType": "module", 9 | "ecmaFeatures": { 10 | "jsx": true 11 | } 12 | }, 13 | "settings": { 14 | "react": { 15 | "version": "16.4.0" 16 | } 17 | }, 18 | "rules": { 19 | "eol-last": 2, 20 | "spaced-comment": 2, 21 | "no-unused-vars": 0, 22 | "no-this-before-super": 2, 23 | "react/jsx-uses-react": "error", 24 | "react/jsx-uses-vars": "error", 25 | "react/no-unknown-property": 2, 26 | "react/jsx-no-undef": 2 27 | } 28 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:react-hooks/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:react/jsx-runtime", 13 | "prettier" 14 | ], 15 | "overrides": [ 16 | { 17 | "files": ["**/patternfly-docs/pages/*"], 18 | "rules": { 19 | "arrow-body-style": "off" 20 | } 21 | } 22 | ], 23 | "parserOptions": { 24 | "ecmaVersion": "latest", 25 | "sourceType": "module", 26 | "ecmaFeatures": { 27 | "jsx": true 28 | } 29 | }, 30 | "settings": { 31 | "react": { 32 | "version": "detect" 33 | } 34 | }, 35 | "plugins": ["react", "react-hooks", "@typescript-eslint"], 36 | "rules": { 37 | "@typescript-eslint/no-explicit-any": "off", 38 | "@typescript-eslint/no-inferrable-types": "off", 39 | "@typescript-eslint/ban-types": "off", 40 | "@typescript-eslint/adjacent-overload-signatures": "error", 41 | "@typescript-eslint/array-type": "error", 42 | "@typescript-eslint/consistent-type-assertions": "error", 43 | "@typescript-eslint/consistent-type-definitions": "error", 44 | "@typescript-eslint/no-misused-new": "error", 45 | "@typescript-eslint/no-namespace": "error", 46 | "@typescript-eslint/no-unused-vars": [ 47 | "error", 48 | { 49 | "argsIgnorePattern": "^_" 50 | } 51 | ], 52 | "@typescript-eslint/prefer-for-of": "error", 53 | "@typescript-eslint/prefer-function-type": "error", 54 | "@typescript-eslint/prefer-namespace-keyword": "error", 55 | "@typescript-eslint/unified-signatures": "error", 56 | "@typescript-eslint/no-var-requires": "off", 57 | "@typescript-eslint/no-implicit-any": "off", 58 | "arrow-body-style": "error", 59 | "camelcase": [ 60 | "error", 61 | { 62 | "ignoreDestructuring": true 63 | } 64 | ], 65 | "constructor-super": "error", 66 | "curly": "error", 67 | "dot-notation": "error", 68 | "eqeqeq": ["error", "smart"], 69 | "guard-for-in": "error", 70 | "max-classes-per-file": ["error", 1], 71 | "no-nested-ternary": "error", 72 | "no-bitwise": "error", 73 | "no-caller": "error", 74 | "no-cond-assign": "error", 75 | "no-console": "error", 76 | "no-debugger": "error", 77 | "no-empty": "error", 78 | "no-eval": "error", 79 | "no-new-wrappers": "error", 80 | "no-undef-init": "error", 81 | "no-unsafe-finally": "error", 82 | "no-unused-expressions": [ 83 | "error", 84 | { 85 | "allowTernary": true, 86 | "allowShortCircuit": true 87 | } 88 | ], 89 | "no-unused-labels": "error", 90 | "no-var": "error", 91 | "object-shorthand": "error", 92 | "one-var": ["error", "never"], 93 | "prefer-const": "error", 94 | "radix": ["error", "as-needed"], 95 | "react/prop-types": 0, 96 | "react/display-name": 0, 97 | "react-hooks/exhaustive-deps": "off", 98 | "react/no-unescaped-entities": ["error", { "forbid": [">", "}"] }], 99 | "spaced-comment": "error", 100 | "use-isnan": "error" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /.github/workflows/build-lint-test.yml: -------------------------------------------------------------------------------- 1 | name: build-lint-test 2 | on: 3 | workflow_call: 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | env: 8 | GH_PR_NUM: ${{ github.event.number }} 9 | steps: 10 | - uses: actions/checkout@v2 11 | - run: | 12 | if [[ ! -z "${GH_PR_NUM}" ]]; then 13 | echo "Checking out PR" 14 | git fetch origin pull/$GH_PR_NUM/head:tmp 15 | git checkout tmp 16 | fi 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: '18' 20 | - uses: actions/cache@v4 21 | id: yarn-cache 22 | name: Cache npm deps 23 | with: 24 | path: | 25 | node_modules 26 | **/node_modules 27 | key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 28 | - run: yarn install --frozen-lockfile 29 | if: steps.yarn-cache.outputs.cache-hit != 'true' 30 | - uses: actions/cache@v4 31 | id: dist 32 | name: Cache dist 33 | with: 34 | path: | 35 | packages/*/dist 36 | key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} 37 | - name: Build dist 38 | run: yarn build 39 | if: steps.dist.outputs.cache-hit != 'true' 40 | lint: 41 | runs-on: ubuntu-latest 42 | env: 43 | GH_PR_NUM: ${{ github.event.number }} 44 | needs: build 45 | steps: 46 | - uses: actions/checkout@v2 47 | - run: | 48 | if [[ ! -z "${GH_PR_NUM}" ]]; then 49 | echo "Checking out PR" 50 | git fetch origin pull/$GH_PR_NUM/head:tmp 51 | git checkout tmp 52 | fi 53 | - uses: actions/setup-node@v3 54 | with: 55 | node-version: '20' 56 | - uses: actions/cache@v4 57 | id: yarn-cache 58 | name: Cache npm deps 59 | with: 60 | path: | 61 | node_modules 62 | **/node_modules 63 | key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 64 | - run: yarn install --frozen-lockfile 65 | if: steps.yarn-cache.outputs.cache-hit != 'true' 66 | - uses: actions/cache@v4 67 | id: lint-cache 68 | name: Load lint cache 69 | with: 70 | path: '.eslintcache' 71 | key: ${{ runner.os }}-lint-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 72 | - name: ESLint 73 | run: yarn lint:js 74 | - name: MDLint 75 | run: yarn lint:md 76 | test_jest: 77 | runs-on: ubuntu-latest 78 | env: 79 | GH_PR_NUM: ${{ github.event.number }} 80 | needs: build 81 | steps: 82 | - uses: actions/checkout@v2 83 | # Yes, we really want to checkout the PR 84 | - run: | 85 | if [[ ! -z "${GH_PR_NUM}" ]]; then 86 | echo "Checking out PR" 87 | git fetch origin pull/$GH_PR_NUM/head:tmp 88 | git checkout tmp 89 | fi 90 | - uses: actions/setup-node@v3 91 | with: 92 | node-version: '20' 93 | - uses: actions/cache@v4 94 | id: yarn-cache 95 | name: Cache npm deps 96 | with: 97 | path: | 98 | node_modules 99 | **/node_modules 100 | ~/.cache/Cypress 101 | key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 102 | - run: yarn install --frozen-lockfile 103 | if: steps.yarn-cache.outputs.cache-hit != 'true' 104 | - uses: actions/cache@v4 105 | id: dist 106 | name: Cache dist 107 | with: 108 | path: | 109 | packages/*/dist 110 | packages/react-styles/css 111 | key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} 112 | - name: Build dist 113 | run: yarn build 114 | if: steps.dist.outputs.cache-hit != 'true' 115 | - name: PF4 Jest Tests 116 | run: yarn test --maxWorkers=2 117 | test_a11y: 118 | runs-on: ubuntu-latest 119 | env: 120 | GH_PR_NUM: ${{ github.event.number }} 121 | needs: build 122 | steps: 123 | - uses: actions/checkout@v2 124 | # Yes, we really want to checkout the PR 125 | - run: | 126 | if [[ ! -z "${GH_PR_NUM}" ]]; then 127 | echo "Checking out PR" 128 | git fetch origin pull/$GH_PR_NUM/head:tmp 129 | git checkout tmp 130 | fi 131 | - uses: actions/setup-node@v3 132 | with: 133 | node-version: '20' 134 | - uses: actions/cache@v4 135 | id: yarn-cache 136 | name: Cache npm deps 137 | with: 138 | path: | 139 | node_modules 140 | **/node_modules 141 | ~/.cache/Cypress 142 | key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 143 | - run: yarn install --frozen-lockfile 144 | if: steps.yarn-cache.outputs.cache-hit != 'true' 145 | - uses: actions/cache@v4 146 | id: dist 147 | name: Cache dist 148 | with: 149 | path: | 150 | packages/*/dist 151 | packages/react-styles/css 152 | key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} 153 | - name: Build dist 154 | run: yarn build 155 | if: steps.dist.outputs.cache-hit != 'true' 156 | - name: Build docs 157 | run: yarn build:docs 158 | - name: A11y tests 159 | run: yarn serve:docs & yarn test:a11y 160 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | workflow_call: 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | env: 8 | GH_PR_NUM: ${{ github.event.number }} 9 | steps: 10 | - uses: actions/checkout@v2 11 | - run: | 12 | if [[ ! -z "${GH_PR_NUM}" ]]; then 13 | echo "Checking out PR" 14 | git fetch origin pull/$GH_PR_NUM/head:tmp 15 | git checkout tmp 16 | fi 17 | - uses: actions/cache@v4 18 | id: setup-cache 19 | name: Cache setup 20 | with: 21 | path: | 22 | README.md 23 | package.json 24 | .tmplr.yml 25 | packages/*/package.json 26 | packages/*/patternfly-docs/content/** 27 | packages/*/patternfly-docs/generated/** 28 | key: ${{ runner.os }}-setup-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('package.json', 'packages/module/package.json') }} 29 | - name: Run build script 30 | run: ./devSetup.sh 31 | shell: bash 32 | if: steps.setup-cache.outputs.cache-hit != 'true' 33 | - uses: actions/setup-node@v3 34 | with: 35 | node-version: '18' 36 | - uses: actions/cache@v4 37 | id: yarn-cache 38 | name: Cache npm deps 39 | with: 40 | path: | 41 | node_modules 42 | **/node_modules 43 | key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 44 | - run: yarn install --frozen-lockfile 45 | if: steps.yarn-cache.outputs.cache-hit != 'true' 46 | - uses: actions/cache@v4 47 | id: dist 48 | name: Cache dist 49 | with: 50 | path: | 51 | packages/*/dist 52 | key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} 53 | - name: Build dist 54 | run: yarn build 55 | if: steps.dist.outputs.cache-hit != 'true' -------------------------------------------------------------------------------- /.github/workflows/check-pr.yml: -------------------------------------------------------------------------------- 1 | name: check-pr 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | call-build-lint-test-workflow: 8 | uses: ./.github/workflows/build-lint-test.yml 9 | -------------------------------------------------------------------------------- /.github/workflows/new_issues.yml: -------------------------------------------------------------------------------- 1 | name: Process new issues 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | 7 | jobs: 8 | add-to-project: 9 | name: Add issue to project 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/add-to-project@v0.3.0 13 | with: 14 | project-url: https://github.com/orgs/patternfly/projects/7 15 | github-token: ${{ secrets.GH_PROJECTS }} 16 | label-issue: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Team Membership Checker 20 | # You may pin to the exact commit or the version. 21 | # uses: TheModdingInquisition/actions-team-membership@a69636a92bc927f32c3910baac06bacc949c984c 22 | uses: TheModdingInquisition/actions-team-membership@v1.0 23 | with: 24 | # Repository token. GitHub Action token is used by default(recommended). But you can also use the other token(e.g. personal access token). 25 | token: ${{ secrets.GH_READ_ORG_TOKEN }} 26 | # The team to check for. 27 | team: 'frequent-flyers' 28 | # The organization of the team to check for. Defaults to the context organization. 29 | organization: 'patternfly' 30 | # If the action should exit if the user is not part of the team. 31 | exit: true 32 | 33 | - name: Add label if user is a team member 34 | run: | 35 | curl -X POST \ 36 | -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 37 | -H "Accept: application/vnd.github.v3+json" \ 38 | https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels \ 39 | -d '{"labels":["PF Team"]}' 40 | -------------------------------------------------------------------------------- /.github/workflows/promote.yml: -------------------------------------------------------------------------------- 1 | name: promote 2 | on: 3 | push: 4 | # Sequence of patterns matched against refs/tags 5 | tags: 6 | - v6.* 7 | jobs: 8 | build-and-promote: 9 | runs-on: ubuntu-latest 10 | env: 11 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 13 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build for promotion 17 | run: yarn install --frozen-lockfile && yarn build 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: '18.x' 21 | registry-url: 'https://registry.npmjs.org' 22 | - name: GitHub Tag Name example 23 | run: | 24 | echo "Tag name from GITHUB_REF_NAME: $GITHUB_REF_NAME" 25 | echo "Tag name from github.ref_name: ${{ github.ref_name }}" 26 | - name: Manual publish 27 | run: | 28 | cd packages/module 29 | npm version ${{ github.ref_name }} --git-tag-version false 30 | npm publish --tag=latest 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | call-build-lint-test-workflow: 8 | uses: ./.github/workflows/build-lint-test.yml 9 | deploy: 10 | runs-on: ubuntu-latest 11 | needs: [call-build-lint-test-workflow] 12 | env: 13 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 14 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: '18' 20 | - uses: actions/cache@v4 21 | id: yarn-cache 22 | name: Cache npm deps 23 | with: 24 | path: | 25 | node_modules 26 | **/node_modules 27 | ~/.cache/Cypress 28 | key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} 29 | - run: yarn install --frozen-lockfile 30 | if: steps.yarn-cache.outputs.cache-hit != 'true' 31 | - uses: actions/cache@v4 32 | id: dist 33 | name: Cache dist 34 | with: 35 | path: | 36 | packages/*/dist 37 | packages/react-styles/css 38 | key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} 39 | - name: Build dist 40 | run: yarn build 41 | if: steps.dist.outputs.cache-hit != 'true' 42 | - name: Release to NPM 43 | run: cd packages/module && npx semantic-release@19.0.5 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Javascript builds 2 | node_modules 3 | dist 4 | tsc_out 5 | .out 6 | .changelog 7 | .DS_Store 8 | coverage 9 | .cache 10 | .tmp 11 | .eslintcache 12 | .cache_* 13 | 14 | # package managers 15 | yarn-error.log 16 | lerna-debug.log 17 | 18 | # IDEs and editors 19 | .idea 20 | .project 21 | .classpath 22 | .c9 23 | *.launch 24 | .settings 25 | *.sublime-workspace 26 | .history 27 | .vscode 28 | .yo-rc.json 29 | 30 | # IDE - VSCode 31 | .vscode 32 | # For vim 33 | *.swp 34 | 35 | public -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Javascript builds 2 | node_modules 3 | dist 4 | tsc_out 5 | .out 6 | .changelog 7 | .DS_Store 8 | coverage 9 | .cache 10 | .tmp 11 | .eslintcache 12 | generated 13 | 14 | # package managers 15 | yarn-error.log 16 | lerna-debug.log 17 | 18 | # IDEs and editors 19 | .idea 20 | .project 21 | .classpath 22 | .c9 23 | *.launch 24 | .settings 25 | *.sublime-workspace 26 | .history 27 | .vscode 28 | .yo-rc.json 29 | 30 | # IDE - VSCode 31 | .vscode 32 | # For vim 33 | *.swp 34 | 35 | public -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "none", 6 | "useTabs": false, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Red Hat, 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 | # React Log Viewer 2 | 3 | Live docs available on [patternfly.org](https://www.patternfly.org/extensions/log-viewer) 4 | 5 | To run the documentation locally, clone this repo and run: 6 | ```bash 7 | yarn install && yarn build && yarn start 8 | ``` 9 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | ['@babel/preset-react', { runtime: 'automatic' }], 5 | '@babel/preset-flow', 6 | '@babel/preset-typescript' 7 | ] 8 | }; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testMatch: ['**/__tests__/**/*.{js,ts}?(x)', '**/*.test.{js,ts}?(x)'], 4 | modulePathIgnorePatterns: [ 5 | '/packages/*.*/dist/*.*', 6 | '/packages/*.*/public/*.*', 7 | '/packages/*.*/.cache/*.*' 8 | ], 9 | roots: ['/packages'], 10 | transform: { 11 | '^.+\\.[jt]sx?$': 'babel-jest' 12 | }, 13 | moduleNameMapper: { 14 | '\\.(css|less)$': '/styleMock.js' 15 | }, 16 | testEnvironment: 'jsdom', 17 | setupFiles: ['./jest.setup.js'] 18 | }; 19 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const { TextEncoder } = require('util'); 2 | global.TextEncoder = TextEncoder; 3 | -------------------------------------------------------------------------------- /module.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra'); 2 | const glob = require('glob'); 3 | const path = require('path'); 4 | 5 | const root = process.cwd(); 6 | 7 | const sourceFiles = glob 8 | .sync(`${root}/src/*/`) 9 | .map((name) => name.replace(/\/$/, '')); 10 | 11 | const indexTypings = glob.sync(`${root}/src/index.d.ts`); 12 | 13 | async function copyTypings(files, dest) { 14 | const cmds = []; 15 | files.forEach((file) => { 16 | const fileName = file.split('/').pop(); 17 | cmds.push(fse.copyFile(file, `${dest}/${fileName}`)); 18 | }); 19 | return Promise.all(cmds); 20 | } 21 | 22 | async function createPackage(file) { 23 | const fileName = file.split('/').pop(); 24 | const esmSource = glob.sync(`${root}/esm/${fileName}/**/index.js`)[0]; 25 | /** 26 | * Prevent creating package.json for directories with no JS files (like CSS directories) 27 | */ 28 | if (!esmSource) { 29 | return; 30 | } 31 | 32 | const destFile = `${path.resolve(root, file.split('/src/').pop())}/package.json`; 33 | 34 | const esmRelative = path.relative(file.replace('/src', ''), esmSource); 35 | const content = { 36 | main: 'index.js', 37 | module: esmRelative, 38 | }; 39 | const typings = glob.sync(`${root}/src/${fileName}/*.d.ts`); 40 | let cmds = []; 41 | content.typings = 'index.d.ts'; 42 | cmds.push(copyTypings(typings, `${root}/${fileName}`)); 43 | cmds.push(fse.writeJSON(destFile, content)); 44 | return Promise.all(cmds); 45 | } 46 | 47 | async function generatePackages(files) { 48 | const cmds = files.map((file) => createPackage(file)); 49 | return Promise.all(cmds); 50 | } 51 | 52 | async function run(files) { 53 | try { 54 | await generatePackages(files); 55 | if (indexTypings.length === 1) { 56 | copyTypings(indexTypings, root); 57 | } 58 | } catch (error) { 59 | console.error(error); 60 | process.exit(1); 61 | } 62 | } 63 | 64 | run(sourceFiles); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@patternfly/react-log-viewer-root", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "This library provides patternfly extensions", 6 | "license": "MIT", 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "scripts": { 11 | "build": "yarn workspace @patternfly/react-log-viewer build", 12 | "build:watch": "npm run build:watch -w @patternfly/react-log-viewer", 13 | "build:docs": "yarn workspace @patternfly/react-log-viewer docs:build", 14 | "start": "concurrently --kill-others \"npm run build:watch\" \"npm run docs:develop -w @patternfly/react-log-viewer\"", 15 | "serve:docs": "yarn workspace @patternfly/react-log-viewer docs:serve", 16 | "clean": "yarn workspace @patternfly/react-log-viewer clean", 17 | "lint:js": "node --max-old-space-size=4096 node_modules/.bin/eslint packages --ext js,jsx,ts,tsx --cache", 18 | "lint:md": "yarn eslint packages --ext md --no-eslintrc --config .eslintrc-md.json --cache", 19 | "lint": "yarn lint:js && yarn lint:md", 20 | "test": "TZ=EST jest packages", 21 | "test:a11y": "yarn workspace @patternfly/react-log-viewer test:a11y", 22 | "serve:a11y": "yarn workspace @patternfly/react-log-viewer serve:a11y" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.19.6", 26 | "@babel/preset-env": "^7.19.4", 27 | "@babel/preset-react": "^7.18.6", 28 | "@babel/preset-flow": "^7.18.6", 29 | "@babel/preset-typescript": "^7.18.6", 30 | "@testing-library/react": "^13.4.0", 31 | "@testing-library/user-event": "14.4.3", 32 | "@testing-library/jest-dom": "5.16.5", 33 | "@testing-library/dom": "9.0.0", 34 | "@typescript-eslint/eslint-plugin": "^5.42.0", 35 | "@typescript-eslint/parser": "^5.42.0", 36 | "@types/react": "^18", 37 | "@types/react-dom": "^18", 38 | "concurrently": "^5.3.0", 39 | "eslint": "^8.0.1", 40 | "eslint-plugin-import": "^2.25.2", 41 | "eslint-plugin-markdown": "^1.0.2", 42 | "eslint-plugin-prettier": "^3.1.4", 43 | "eslint-plugin-react": "^7.21.4", 44 | "eslint-config-standard-with-typescript": "^23.0.0", 45 | "eslint-plugin-n": "^15.0.0", 46 | "eslint-plugin-promise": "^6.0.0", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "eslint-config-prettier": "8.5.0", 49 | "prettier": "2.7.1", 50 | "jest": "^29.2.2", 51 | "babel-jest": "^29.2.2", 52 | "jest-environment-jsdom": "^29.2.2", 53 | "jest-canvas-mock": "^2.4.0", 54 | "react": "^18", 55 | "react-dom": "^18", 56 | "rimraf": "^2.6.2", 57 | "serve": "^14.1.2", 58 | "typescript": "^4.7.4" 59 | }, 60 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 61 | } 62 | -------------------------------------------------------------------------------- /packages/module/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@patternfly/react-log-viewer", 3 | "version": "6.0.0-prerelease.0", 4 | "description": "Terminal renderer", 5 | "main": "dist/js/index.js", 6 | "module": "dist/esm/index.js", 7 | "publishConfig": { 8 | "access": "public", 9 | "tag": "prerelease" 10 | }, 11 | "scripts": { 12 | "build": "yarn generate && yarn build:esm && yarn build:cjs", 13 | "build:watch": "npm run build:esm -- --watch", 14 | "build:esm": "tsc --build --verbose ./tsconfig.json", 15 | "build:cjs": "tsc --build --verbose ./tsconfig.cjs.json", 16 | "clean": "rimraf dist", 17 | "docs:develop": "pf-docs-framework start", 18 | "docs:build": "pf-docs-framework build all --output public", 19 | "docs:serve": "pf-docs-framework serve public --port 5001", 20 | "docs:screenshots": "pf-docs-framework screenshots --urlPrefix http://localhost:5000", 21 | "generate": "yarn clean && node scripts/writeClassMaps.js", 22 | "test:a11y": "patternfly-a11y --config patternfly-a11y.config", 23 | "serve:a11y": "yarn serve coverage" 24 | }, 25 | "repository": "git+https://github.com/patternfly/react-log-viewer.git", 26 | "author": "Red Hat", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/patternfly/react-log-viewer/issues" 30 | }, 31 | "homepage": "https://github.com/patternfly/react-log-viewer#readme", 32 | "dependencies": { 33 | "@patternfly/react-core": "^6.0.0", 34 | "@patternfly/react-icons": "^6.0.0", 35 | "@patternfly/react-styles": "^6.0.0", 36 | "memoize-one": "^5.1.0" 37 | }, 38 | "peerDependencies": { 39 | "react": "^17 || ^18 || ^19", 40 | "react-dom": "^17 || ^18 || ^19" 41 | }, 42 | "devDependencies": { 43 | "@patternfly/documentation-framework": "^6.0.0-alpha.120", 44 | "@patternfly/patternfly": "^6.0.0", 45 | "@patternfly/react-table": "^6.0.0", 46 | "@patternfly/patternfly-a11y": "^4.3.1", 47 | "@patternfly/react-code-editor": "^6.0.0", 48 | "resize-observer-polyfill": "^1.5.1", 49 | "tslib": "^2.0.0", 50 | "react-monaco-editor": "^0.51.0", 51 | "monaco-editor": "^0.34.1", 52 | "camel-case": "^3.0.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/module/patternfly-a11y.config.js: -------------------------------------------------------------------------------- 1 | const { fullscreenRoutes } = require('@patternfly/documentation-framework/routes'); 2 | 3 | /** 4 | * Wait for a selector before running axe 5 | * 6 | * @param page page from puppeteer 7 | */ 8 | async function waitFor(page) { 9 | await page.waitForSelector('#root > *'); 10 | } 11 | 12 | const urls = Object.keys(fullscreenRoutes) 13 | .map((key) => (fullscreenRoutes[key].isFullscreenOnly ? key : fullscreenRoutes[key].path.replace(/\/react$/, ''))) 14 | .reduce((result, item) => (result.includes(item) ? result : [...result, item]), []); 15 | 16 | module.exports = { 17 | prefix: 'http://localhost:5001', 18 | waitFor, 19 | crawl: false, 20 | urls: [...urls], 21 | ignoreRules: [ 22 | 'color-contrast', 23 | 'landmark-no-duplicate-main', 24 | 'landmark-main-is-top-level', 25 | 'scrollable-region-focusable' 26 | ].join(','), 27 | ignoreIncomplete: true 28 | }; 29 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/demos/ComplexToolbarLogViewer.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect, Fragment } from 'react'; 2 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 3 | import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; 4 | import { 5 | Badge, 6 | Button, 7 | MenuToggle, 8 | Select, 9 | SelectList, 10 | SelectOption, 11 | Tooltip, 12 | Toolbar, 13 | ToolbarContent, 14 | ToolbarGroup, 15 | ToolbarItem, 16 | ToolbarToggleGroup 17 | } from '@patternfly/react-core'; 18 | import OutlinedPlayCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-play-circle-icon'; 19 | import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; 20 | import PauseIcon from '@patternfly/react-icons/dist/esm/icons/pause-icon'; 21 | import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon'; 22 | import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; 23 | import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; 24 | 25 | export const ComplexToolbarLogViewer = () => { 26 | const dataSources = { 27 | 'container-1': { type: 'C', id: 'data1' }, 28 | 'container-2': { type: 'D', id: 'data2' }, 29 | 'container-3': { type: 'E', id: 'data3' } 30 | }; 31 | const [isPaused, setIsPaused] = useState(false); 32 | const [isFullScreen, setIsFullScreen] = useState(false); 33 | const [itemCount, setItemCount] = useState(1); 34 | const [currentItemCount, setCurrentItemCount] = useState(0); 35 | const [renderData, setRenderData] = useState(''); 36 | const [selectedDataSource, setSelectedDataSource] = useState('container-1'); 37 | const [selectDataSourceOpen, setSelectDataSourceOpen] = useState(false); 38 | const [timer, setTimer] = useState(null); 39 | const [selectedData, setSelectedData] = useState(data[dataSources[selectedDataSource].id].split('\n')); 40 | const [buffer, setBuffer] = useState([]); 41 | const [linesBehind, setLinesBehind] = useState(0); 42 | const logViewerRef = useRef(null); 43 | 44 | useEffect(() => { 45 | setTimer( 46 | window.setInterval(() => { 47 | setItemCount((itemCount) => itemCount + 1); 48 | }, 500) 49 | ); 50 | return () => { 51 | window.clearInterval(timer); 52 | }; 53 | }, []); 54 | 55 | useEffect(() => { 56 | if (itemCount > selectedData.length) { 57 | window.clearInterval(timer); 58 | } else { 59 | setBuffer(selectedData.slice(0, itemCount)); 60 | } 61 | }, [itemCount]); 62 | 63 | useEffect(() => { 64 | if (!isPaused && buffer.length > 0) { 65 | setCurrentItemCount(buffer.length); 66 | setRenderData(buffer.join('\n')); 67 | if (logViewerRef && logViewerRef.current) { 68 | logViewerRef.current.scrollToBottom(); 69 | } 70 | } else if (buffer.length !== currentItemCount) { 71 | setLinesBehind(buffer.length - currentItemCount); 72 | } else { 73 | setLinesBehind(0); 74 | } 75 | }, [isPaused, buffer]); 76 | 77 | // Listening escape key on full screen mode. 78 | useEffect(() => { 79 | const handleFullscreenChange = () => { 80 | const isFullscreen = 81 | document.fullscreenElement || 82 | document.mozFullScreenElement || 83 | document.webkitFullscreenElement || 84 | document.msFullscreenElement; 85 | 86 | setIsFullScreen(!!isFullscreen); 87 | }; 88 | 89 | document.addEventListener('fullscreenchange', handleFullscreenChange); 90 | document.addEventListener('mozfullscreenchange', handleFullscreenChange); 91 | document.addEventListener('webkitfullscreenchange', handleFullscreenChange); 92 | document.addEventListener('msfullscreenchange', handleFullscreenChange); 93 | 94 | return () => { 95 | document.removeEventListener('fullscreenchange', handleFullscreenChange); 96 | document.removeEventListener('mozfullscreenchange', handleFullscreenChange); 97 | document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); 98 | document.removeEventListener('msfullscreenchange', handleFullscreenChange); 99 | }; 100 | }, []); 101 | 102 | const onExpandClick = (_event) => { 103 | const element = document.querySelector('#complex-toolbar-demo'); 104 | 105 | if (!isFullScreen) { 106 | if (element.requestFullscreen) { 107 | element.requestFullscreen(); 108 | } else if (element.mozRequestFullScreen) { 109 | element.mozRequestFullScreen(); 110 | } else if (element.webkitRequestFullScreen) { 111 | element.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT); 112 | } 113 | setIsFullScreen(true); 114 | } else { 115 | if (document.exitFullscreen) { 116 | document.exitFullscreen(); 117 | } else if (document.webkitExitFullscreen) { 118 | /* Safari */ 119 | document.webkitExitFullscreen(); 120 | } else if (document.msExitFullscreen) { 121 | /* IE11 */ 122 | document.msExitFullscreen(); 123 | } 124 | setIsFullScreen(false); 125 | } 126 | }; 127 | 128 | const onDownloadClick = () => { 129 | const element = document.createElement('a'); 130 | const dataToDownload = [data[dataSources[selectedDataSource].id]]; 131 | const file = new Blob(dataToDownload, { type: 'text/plain' }); 132 | element.href = URL.createObjectURL(file); 133 | element.download = `${selectedDataSource}.txt`; 134 | document.body.appendChild(element); 135 | element.click(); 136 | document.body.removeChild(element); 137 | }; 138 | 139 | const onToggleClick = () => { 140 | setSelectDataSourceOpen(!selectDataSourceOpen); 141 | }; 142 | 143 | const onScroll = ({ scrollOffsetToBottom, _scrollDirection, scrollUpdateWasRequested }) => { 144 | if (!scrollUpdateWasRequested) { 145 | if (scrollOffsetToBottom > 0) { 146 | setIsPaused(true); 147 | } else { 148 | setIsPaused(false); 149 | } 150 | } 151 | }; 152 | 153 | const selectDataSourceMenu = Object.entries(dataSources).map(([value, { type }]) => ( 154 | 160 | {type} 161 | {` ${value}`} 162 | 163 | )); 164 | 165 | const selectDataSourcePlaceholder = ( 166 | 167 | {dataSources[selectedDataSource].type} 168 | {` ${selectedDataSource}`} 169 | 170 | ); 171 | 172 | const ControlButton = () => ( 173 | 182 | ); 183 | 184 | const toggle = (toggleRef) => ( 185 | 186 | {selectDataSourcePlaceholder} 187 | 188 | ); 189 | 190 | const leftAlignedToolbarGroup = ( 191 | 192 | } breakpoint="md"> 193 | 194 | 212 | 213 | 214 | setIsPaused(true)} placeholder="Search" /> 215 | 216 | 217 | 218 | 219 | 220 | 221 | ); 222 | 223 | const rightAlignedToolbarGroup = ( 224 | 225 | 226 | 227 | Download}> 228 | 231 | 232 | 233 | 234 | Expand}> 235 | 238 | 239 | 240 | 241 | 242 | ); 243 | 244 | const FooterButton = () => { 245 | const handleClick = (_e) => { 246 | setIsPaused(false); 247 | }; 248 | return ( 249 | 253 | ); 254 | }; 255 | return ( 256 | 264 | 265 | {leftAlignedToolbarGroup} 266 | {rightAlignedToolbarGroup} 267 | 268 | 269 | } 270 | overScanCount={10} 271 | footer={isPaused && } 272 | onScroll={onScroll} 273 | /> 274 | ); 275 | }; 276 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/demos/LogViewer.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: Log viewer 3 | section: extensions 4 | source: react-demos 5 | --- 6 | 7 | import { useState, useRef, useEffect, Fragment } from 'react'; 8 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 9 | import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; 10 | import { 11 | Badge, 12 | Button, 13 | MenuToggle, 14 | Select, 15 | SelectList, 16 | SelectOption, 17 | Tooltip, 18 | Toolbar, 19 | ToolbarContent, 20 | ToolbarGroup, 21 | ToolbarItem, 22 | ToolbarToggleGroup 23 | } from '@patternfly/react-core'; 24 | import OutlinedPlayCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-play-circle-icon'; 25 | import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; 26 | import PauseIcon from '@patternfly/react-icons/dist/esm/icons/pause-icon'; 27 | import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon'; 28 | import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; 29 | import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; 30 | 31 | ### With complex toolbar 32 | 33 | ```js file='./ComplexToolbarLogViewer.jsx' 34 | 35 | ``` 36 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/design-guidelines.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Sidenav top-level section 3 | # should be the same for all markdown files for each extension 4 | section: extensions 5 | # Sidenav secondary level section 6 | # should be the same for all markdown files for each extension 7 | id: Log viewer 8 | # Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) 9 | source: design-guidelines 10 | --- 11 | 12 | A **log viewer** is a preconfigured component that gives you the option to visualize your log content. Log viewer renders log output in real time in a clear and structured way. 13 | 14 | ## Light theme log viewer 15 | 16 | Log Viewer 17 | 18 | 1. **Type of log dropdown menu:** Allow users to switch between different types of logs. 19 | 2. **Search bar:** Use to look up historical logs. The results will be highlighted in the log. 20 | 3. **Pause button:** Play and stop your log content viewing, instead of scrolling through. 21 | 4. **Clear log:** Clear the displayed log output. 22 | 5. **Cog:** House settings such as wrapping lines, showing timestamps, and displaying line numbers. 23 | 6. **Export:** Export log content. 24 | 7. **Download:** Download the log file. 25 | 8. **Fullscreen:** Expand log viewer to full screen. 26 | 27 | ## Dark theme log viewer 28 | We recommend using the light theme editor by default, but there is also a dark theme log viewer 29 | available. All log viewer functionality remains the same whether a light or dark theme is used. 30 | 31 | Dark theme log viewer 32 | 33 | ## Usability 34 | Use a log viewer when: 35 | * The user can manipulate 1 large log file or multiple log files at the same time. 36 | * You want to create a more readable and accessible environment for the user. 37 | * The user may actively search for specific logs within a large log file. 38 | * The user shares files frequently with other users or teams. 39 | 40 | ## Log viewer functionality 41 | 42 | ### With popover 43 | The clear log button opens up a popover with further options, to prevent a user from accidentally clearing their log content. 44 | 45 | Clear this log popover open on a Log viewer 46 | 47 | ### With dropdown, drilldown, and search expanded 48 | The cog icon in the log viewer toolbar allows you to store content options such as timestamps or line numbers for better orientation within the log viewer. 49 | 50 | Cog options open on a Log viewer 51 | 52 | ### With search results 53 | The search bar provides highlighted search results for better findability within a log viewer’s content. 54 | 55 | Log Viewer with highlighted search results 56 | 57 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patternfly/react-log-viewer/ae3436b0fc59f752f8d192c241a133f1cad59aad/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewer.png -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewerclear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patternfly/react-log-viewer/ae3436b0fc59f752f8d192c241a133f1cad59aad/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewerclear.png -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewercog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patternfly/react-log-viewer/ae3436b0fc59f752f8d192c241a133f1cad59aad/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewercog.png -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewerdark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patternfly/react-log-viewer/ae3436b0fc59f752f8d192c241a133f1cad59aad/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewerdark.png -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewersearch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patternfly/react-log-viewer/ae3436b0fc59f752f8d192c241a133f1cad59aad/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/img/logviewersearch.png -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/ANSIColorLogViewer.jsx: -------------------------------------------------------------------------------- 1 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 2 | import { LogViewer } from '@patternfly/react-log-viewer'; 3 | 4 | export const ANSIColorLogViewer = () => ( 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/BasicLogViewer.jsx: -------------------------------------------------------------------------------- 1 | import { useState, Fragment } from 'react'; 2 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 3 | import { LogViewer } from '@patternfly/react-log-viewer'; 4 | import { Checkbox } from '@patternfly/react-core'; 5 | 6 | export const BasicLogViewer = () => { 7 | const [isDarkTheme, setIsDarkTheme] = useState(false); 8 | 9 | return ( 10 | 11 | setIsDarkTheme(value)} 15 | aria-label="toggle dark theme checkbox" 16 | id="toggle-dark-theme" 17 | name="toggle-dark-theme" 18 | /> 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/BasicSearchLogViewer.jsx: -------------------------------------------------------------------------------- 1 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 2 | import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; 3 | import { Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core'; 4 | 5 | export const BasicSearchLogViewer = () => ( 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | } 17 | /> 18 | ); 19 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/CustomControlLogViewer.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { useState } from 'react'; 3 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 4 | import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; 5 | import { Toolbar, ToolbarContent, ToolbarItem, Button, Checkbox } from '@patternfly/react-core'; 6 | 7 | export const CustomControlLogViewer = () => { 8 | const [isTextWrapped, setIsTextWrapped] = useState(false); 9 | const onActionClick = () => { 10 | console.log('clicked test action button'); 11 | }; 12 | 13 | const onPrintClick = () => { 14 | console.log('clicked console print button'); 15 | }; 16 | 17 | return ( 18 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | setIsTextWrapped(value)} 44 | /> 45 | 46 | 47 | 48 | } 49 | /> 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/FooterComponentLogViewer.jsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 3 | import { LogViewer } from '@patternfly/react-log-viewer'; 4 | import { Button } from '@patternfly/react-core'; 5 | 6 | export const FooterComponentLogViewer = () => { 7 | const logViewerRef = useRef(null); 8 | const FooterButton = () => { 9 | const handleClick = () => { 10 | logViewerRef.current.scrollToBottom(); 11 | }; 12 | return ; 13 | }; 14 | 15 | return ( 16 | } 23 | /> 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/HeaderComponentLogViewer.jsx: -------------------------------------------------------------------------------- 1 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 2 | import { LogViewer } from '@patternfly/react-log-viewer'; 3 | import { Banner } from '@patternfly/react-core'; 4 | 5 | export const HeaderComponentLogViewer = () => ( 6 | 5019 lines} /> 7 | ); 8 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | # Sidenav top-level section 3 | # should be the same for all markdown files 4 | section: extensions 5 | # Sidenav secondary level section 6 | # should be the same for all markdown files 7 | id: Log viewer 8 | # Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) 9 | source: react 10 | # Link to source on GitHub 11 | sourceLink: https://github.com/patternfly/react-log-viewer/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/basic.md 12 | # If you use typescript, the name of the interface to display props for 13 | # These are found through the sourceProps function provdided in patternfly-docs.source.js 14 | propComponents: [LogViewer, LogViewerSearch] 15 | --- 16 | 17 | import { useState, Fragment, useRef } from 'react'; 18 | import { LogViewer, LogViewerSearch, LogViewerContext } from '@patternfly/react-log-viewer'; 19 | import { Button, Checkbox, Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core'; 20 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 21 | 22 | ## Examples 23 | 24 | ### Basic 25 | 26 | ```js file='./BasicLogViewer.jsx' 27 | 28 | ``` 29 | 30 | ### With search 31 | 32 | ```js file='./BasicSearchLogViewer.jsx' 33 | 34 | ``` 35 | 36 | ### With complex toolbar 37 | 38 | ```js file='./CustomControlLogViewer.jsx' 39 | 40 | ``` 41 | 42 | ### With header component 43 | 44 | ```js file='./HeaderComponentLogViewer.jsx' 45 | 46 | ``` 47 | 48 | ### With footer component 49 | 50 | ```js file='./FooterComponentLogViewer.jsx' 51 | 52 | ``` 53 | 54 | ### With ANSI color logs 55 | 56 | ```js file='./ANSIColorLogViewer.jsx' 57 | 58 | ``` 59 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/extensions/log-viewer/design-guidelines.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; 3 | import srcImport5 from '../../../content/extensions/react-log-viewer/design-guidelines/./img/logviewer.png'; 4 | import srcImport6 from '../../../content/extensions/react-log-viewer/design-guidelines/./img/logviewerdark.png'; 5 | import srcImport7 from '../../../content/extensions/react-log-viewer/design-guidelines/./img/logviewerclear.png'; 6 | import srcImport8 from '../../../content/extensions/react-log-viewer/design-guidelines/./img/logviewercog.png'; 7 | import srcImport9 from '../../../content/extensions/react-log-viewer/design-guidelines/./img/logviewersearch.png'; 8 | const pageData = { 9 | "id": "Log viewer", 10 | "section": "extensions", 11 | "subsection": "", 12 | "deprecated": false, 13 | "template": false, 14 | "beta": false, 15 | "demo": false, 16 | "newImplementationLink": false, 17 | "source": "design-guidelines", 18 | "tabName": null, 19 | "slug": "/extensions/log-viewer/design-guidelines", 20 | "sourceLink": "https://github.com/patternfly/patternfly-org/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/design-guidelines.md", 21 | "relPath": "packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/design-guidelines.md" 22 | }; 23 | pageData.examples = { 24 | 25 | }; 26 | 27 | const Component = () => ( 28 | 29 |

30 | {`A `} 31 | 32 | {`log viewer`} 33 | 34 | {` is a preconfigured component that gives you the option to visualize your log content. Log viewer renders log output in real time in a clear and structured way.`} 35 |

36 | 37 | {`Light theme log viewer`} 38 | 39 | 40 | 41 |
    42 |
  1. 43 | 44 | {`Type of log dropdown menu:`} 45 | 46 | {` Allow users to switch between different types of logs.`} 47 |
  2. 48 |
  3. 49 | 50 | {`Search bar:`} 51 | 52 | {` Use to look up historical logs. The results will be highlighted in the log.`} 53 |
  4. 54 |
  5. 55 | 56 | {`Pause button:`} 57 | 58 | {` Play and stop your log content viewing, instead of scrolling through.`} 59 |
  6. 60 |
  7. 61 | 62 | {`Clear log:`} 63 | 64 | {` Clear the displayed log output.`} 65 |
  8. 66 |
  9. 67 | 68 | {`Cog:`} 69 | 70 | {` House settings such as wrapping lines, showing timestamps, and displaying line numbers.`} 71 |
  10. 72 |
  11. 73 | 74 | {`Export:`} 75 | 76 | {` Export log content.`} 77 |
  12. 78 |
  13. 79 | 80 | {`Download:`} 81 | 82 | {` Download the log file.`} 83 |
  14. 84 |
  15. 85 | 86 | {`Fullscreen:`} 87 | 88 | {` Expand log viewer to full screen.`} 89 |
  16. 90 |
91 | 92 | {`Dark theme log viewer`} 93 | 94 |

95 | {`We recommend using the light theme editor by default, but there is also a dark theme log viewer 96 | available. All log viewer functionality remains the same whether a light or dark theme is used.`} 97 |

98 | 99 | 100 | 101 | {`Usability`} 102 | 103 |

104 | {`Use a log viewer when:`} 105 |

106 |
    107 |
  • 108 | {`The user can manipulate 1 large log file or multiple log files at the same time.`} 109 |
  • 110 |
  • 111 | {`You want to create a more readable and accessible environment for the user.`} 112 |
  • 113 |
  • 114 | {`The user may actively search for specific logs within a large log file.`} 115 |
  • 116 |
  • 117 | {`The user shares files frequently with other users or teams.`} 118 |
  • 119 |
120 | 121 | {`Log viewer functionality`} 122 | 123 | 124 | {`With popover`} 125 | 126 |

127 | {`The clear log button opens up a popover with further options, to prevent a user from accidentally clearing their log content.`} 128 |

129 | 130 | 131 | 132 | {`With dropdown, drilldown, and search expanded`} 133 | 134 |

135 | {`The cog icon in the log viewer toolbar allows you to store content options such as timestamps or line numbers for better orientation within the log viewer.`} 136 |

137 | 138 | 139 | 140 | {`With search results`} 141 | 142 |

143 | {`The search bar provides highlighted search results for better findability within a log viewer’s content.`} 144 |

145 | 146 | 147 |
148 | ); 149 | Component.displayName = 'ExtensionsLogViewerDesignGuidelinesDocs'; 150 | Component.pageData = pageData; 151 | 152 | export default Component; 153 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/extensions/log-viewer/extensions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; 3 | import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; 4 | import { 5 | Badge, 6 | Button, 7 | Select, 8 | SelectOption, 9 | PageSection, 10 | PageSectionVariants, 11 | Tooltip, 12 | Toolbar, 13 | ToolbarContent, 14 | ToolbarGroup, 15 | ToolbarItem, 16 | ToolbarToggleGroup 17 | } from '@patternfly/react-core'; 18 | import { data } from '../../../content/extensions/react-log-viewer/demos/../examples/realTestData'; 19 | import { OutlinedPlayCircleIcon } from '@patternfly/react-icons'; 20 | import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; 21 | import PauseIcon from '@patternfly/react-icons/dist/esm/icons/pause-icon'; 22 | import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; 23 | import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon'; 24 | import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; 25 | const pageData = { 26 | "id": "Log viewer", 27 | "section": "extensions", 28 | "source": "extensions", 29 | "slug": "/extensions/log-viewer/extensions", 30 | "sourceLink": "https://github.com/patternfly/patternfly-org/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/demos/LogViewer.md", 31 | "beta": true, 32 | "examples": [ 33 | "With complex toolbar" 34 | ] 35 | }; 36 | pageData.liveContext = { 37 | LogViewer, 38 | LogViewerSearch, 39 | Badge, 40 | Button, 41 | Select, 42 | SelectOption, 43 | PageSection, 44 | PageSectionVariants, 45 | Tooltip, 46 | Toolbar, 47 | ToolbarContent, 48 | ToolbarGroup, 49 | ToolbarItem, 50 | ToolbarToggleGroup, 51 | data, 52 | OutlinedPlayCircleIcon, 53 | ExpandIcon, 54 | PauseIcon, 55 | DownloadIcon, 56 | PlayIcon, 57 | EllipsisVIcon 58 | }; 59 | pageData.relativeImports = { 60 | 61 | }; 62 | pageData.examples = { 63 | 'With complex toolbar': props => 64 | {\n const dataSources = {\n 'container-1': { type: 'C', id: 'data1' },\n 'container-2': { type: 'D', id: 'data2' },\n 'container-3': { type: 'E', id: 'data3' }\n };\n const [isPaused, setIsPaused] = React.useState(false);\n const [isFullScreen, setIsFullScreen] = React.useState(false);\n const [itemCount, setItemCount] = React.useState(1);\n const [currentItemCount, setCurrentItemCount] = React.useState(0);\n const [renderData, setRenderData] = React.useState('');\n const [selectedDataSource, setSelectedDataSource] = React.useState('container-1');\n const [selectDataSourceOpen, setSelectDataSourceOpen] = React.useState(false);\n const [timer, setTimer] = React.useState(null);\n const [selectedData, setSelectedData] = React.useState(data[dataSources[selectedDataSource].id].split('\\n'));\n const [buffer, setBuffer] = React.useState([]);\n const [linesBehind, setLinesBehind] = React.useState(0);\n const logViewerRef = React.useRef();\n\n React.useEffect(() => {\n setTimer(\n window.setInterval(() => {\n setItemCount(itemCount => itemCount + 1);\n }, 500)\n );\n return () => {\n window.clearInterval(timer);\n };\n }, []);\n\n React.useEffect(() => {\n if (itemCount > selectedData.length) {\n window.clearInterval(timer);\n } else {\n setBuffer(selectedData.slice(0, itemCount));\n }\n }, [itemCount]);\n\n React.useEffect(() => {\n if (!isPaused && buffer.length > 0) {\n setCurrentItemCount(buffer.length);\n setRenderData(buffer.join('\\n'));\n if (logViewerRef && logViewerRef.current) {\n logViewerRef.current.scrollToBottom();\n }\n } else if (buffer.length !== currentItemCount) {\n setLinesBehind(buffer.length - currentItemCount);\n } else {\n setLinesBehind(0);\n }\n }, [isPaused, buffer]);\n\n const onExpandClick = event => {\n const element = document.querySelector('#complex-toolbar-demo');\n\n if (!isFullScreen) {\n if (element.requestFullscreen) {\n element.requestFullscreen();\n } else if (element.mozRequestFullScreen) {\n element.mozRequestFullScreen();\n } else if (element.webkitRequestFullScreen) {\n element.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT);\n }\n setIsFullScreen(true);\n } else {\n if (document.exitFullscreen) {\n document.exitFullscreen();\n } else if (document.webkitExitFullscreen) {\n /* Safari */\n document.webkitExitFullscreen();\n } else if (document.msExitFullscreen) {\n /* IE11 */\n document.msExitFullscreen();\n }\n setIsFullScreen(false);\n }\n };\n\n const onDownloadClick = () => {\n const element = document.createElement('a');\n const dataToDownload = [data[dataSources[selectedDataSource].id]];\n const file = new Blob(dataToDownload, { type: 'text/plain' });\n element.href = URL.createObjectURL(file);\n element.download = `${selectedDataSource}.txt`;\n document.body.appendChild(element);\n element.click();\n document.body.removeChild(element);\n };\n\n const onScroll = ({ scrollOffsetToBottom, scrollDirection, scrollUpdateWasRequested }) => {\n if (!scrollUpdateWasRequested) {\n if (scrollOffsetToBottom > 0) {\n setIsPaused(true);\n } else {\n setIsPaused(false);\n }\n }\n };\n\n const selectDataSourceMenu = Object.entries(dataSources).map(([value, { type }]) => (\n \n {type}\n {` ${value}`}\n \n ));\n\n const selectDataSourcePlaceholder = (\n \n {dataSources[selectedDataSource].type}\n {` ${selectedDataSource}`}\n \n );\n\n const ControlButton = () => {\n return (\n {\n setIsPaused(!isPaused);\n }}\n >\n {isPaused ? : }\n {isPaused ? ` Resume log` : ` Pause log`}\n \n );\n };\n\n const leftAlignedToolbarGroup = (\n \n } breakpoint=\"md\">\n \n setSelectDataSourceOpen(isOpen)}\n onSelect={(event, selection) => {\n setSelectDataSourceOpen(false);\n setSelectedDataSource(selection);\n setSelectedData(data[dataSources[selection].id].split('\\n'));\n setLinesBehind(0);\n setBuffer([]);\n setItemCount(1);\n setCurrentItemCount(0);\n }}\n selections={selectedDataSource}\n isOpen={selectDataSourceOpen}\n placeholderText={selectDataSourcePlaceholder}\n >\n {selectDataSourceMenu}\n \n \n \n setIsPaused(true)} placeholder=\"Search\" />\n \n \n \n \n \n \n );\n\n const rightAlignedToolbarGroup = (\n \n \n \n Download}>\n \n \n \n \n Expand}>\n \n \n \n \n \n );\n\n const FooterButton = () => {\n const handleClick = e => {\n setIsPaused(false);\n };\n return (\n \n );\n };\n return (\n \n \n {leftAlignedToolbarGroup}\n {rightAlignedToolbarGroup}\n \n \n }\n overScanCount={10}\n footer={isPaused && }\n onScroll={onScroll}\n />\n );\n};\n","title":"With complex toolbar","lang":"js"}}> 65 | 66 | 67 | }; 68 | 69 | const Component = () => ( 70 | 71 | {React.createElement(pageData.examples["With complex toolbar"])} 72 | 73 | ); 74 | Component.displayName = 'ExtensionsLogViewerExtensionsDocs'; 75 | Component.pageData = pageData; 76 | 77 | export default Component; 78 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/extensions/log-viewer/react-demos.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; 3 | import { useState, useRef, useEffect, Fragment } from 'react'; 4 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 5 | import { LogViewer, LogViewerSearch } from '@patternfly/react-log-viewer'; 6 | import { 7 | Badge, 8 | Button, 9 | MenuToggle, 10 | Select, 11 | SelectList, 12 | SelectOption, 13 | Tooltip, 14 | Toolbar, 15 | ToolbarContent, 16 | ToolbarGroup, 17 | ToolbarItem, 18 | ToolbarToggleGroup 19 | } from '@patternfly/react-core'; 20 | import OutlinedPlayCircleIcon from '@patternfly/react-icons/dist/esm/icons/outlined-play-circle-icon'; 21 | import ExpandIcon from '@patternfly/react-icons/dist/esm/icons/expand-icon'; 22 | import PauseIcon from '@patternfly/react-icons/dist/esm/icons/pause-icon'; 23 | import PlayIcon from '@patternfly/react-icons/dist/esm/icons/play-icon'; 24 | import EllipsisVIcon from '@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon'; 25 | import DownloadIcon from '@patternfly/react-icons/dist/esm/icons/download-icon'; 26 | const pageData = { 27 | "id": "Log viewer", 28 | "section": "extensions", 29 | "subsection": "", 30 | "deprecated": false, 31 | "template": false, 32 | "beta": false, 33 | "demo": false, 34 | "newImplementationLink": false, 35 | "source": "react-demos", 36 | "tabName": null, 37 | "slug": "/extensions/log-viewer/react-demos", 38 | "sourceLink": "https://github.com/patternfly/patternfly-react/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/demos/LogViewer.md", 39 | "relPath": "packages/module/patternfly-docs/content/extensions/react-log-viewer/demos/LogViewer.md", 40 | "examples": [ 41 | "With complex toolbar" 42 | ] 43 | }; 44 | pageData.liveContext = { 45 | useState, 46 | useRef, 47 | useEffect, 48 | Fragment, 49 | data, 50 | LogViewer, 51 | LogViewerSearch, 52 | Badge, 53 | Button, 54 | MenuToggle, 55 | Select, 56 | SelectList, 57 | SelectOption, 58 | Tooltip, 59 | Toolbar, 60 | ToolbarContent, 61 | ToolbarGroup, 62 | ToolbarItem, 63 | ToolbarToggleGroup, 64 | OutlinedPlayCircleIcon, 65 | ExpandIcon, 66 | PauseIcon, 67 | PlayIcon, 68 | EllipsisVIcon, 69 | DownloadIcon 70 | }; 71 | pageData.examples = { 72 | 'With complex toolbar': props => 73 | {\n const dataSources = {\n 'container-1': { type: 'C', id: 'data1' },\n 'container-2': { type: 'D', id: 'data2' },\n 'container-3': { type: 'E', id: 'data3' }\n };\n const [isPaused, setIsPaused] = useState(false);\n const [isFullScreen, setIsFullScreen] = useState(false);\n const [itemCount, setItemCount] = useState(1);\n const [currentItemCount, setCurrentItemCount] = useState(0);\n const [renderData, setRenderData] = useState('');\n const [selectedDataSource, setSelectedDataSource] = useState('container-1');\n const [selectDataSourceOpen, setSelectDataSourceOpen] = useState(false);\n const [timer, setTimer] = useState(null);\n const [selectedData, setSelectedData] = useState(data[dataSources[selectedDataSource].id].split('\\n'));\n const [buffer, setBuffer] = useState([]);\n const [linesBehind, setLinesBehind] = useState(0);\n const logViewerRef = useRef(null);\n\n useEffect(() => {\n setTimer(\n window.setInterval(() => {\n setItemCount((itemCount) => itemCount + 1);\n }, 500)\n );\n return () => {\n window.clearInterval(timer);\n };\n }, []);\n\n useEffect(() => {\n if (itemCount > selectedData.length) {\n window.clearInterval(timer);\n } else {\n setBuffer(selectedData.slice(0, itemCount));\n }\n }, [itemCount]);\n\n useEffect(() => {\n if (!isPaused && buffer.length > 0) {\n setCurrentItemCount(buffer.length);\n setRenderData(buffer.join('\\n'));\n if (logViewerRef && logViewerRef.current) {\n logViewerRef.current.scrollToBottom();\n }\n } else if (buffer.length !== currentItemCount) {\n setLinesBehind(buffer.length - currentItemCount);\n } else {\n setLinesBehind(0);\n }\n }, [isPaused, buffer]);\n\n // Listening escape key on full screen mode.\n useEffect(() => {\n const handleFullscreenChange = () => {\n const isFullscreen =\n document.fullscreenElement ||\n document.mozFullScreenElement ||\n document.webkitFullscreenElement ||\n document.msFullscreenElement;\n\n setIsFullScreen(!!isFullscreen);\n };\n\n document.addEventListener('fullscreenchange', handleFullscreenChange);\n document.addEventListener('mozfullscreenchange', handleFullscreenChange);\n document.addEventListener('webkitfullscreenchange', handleFullscreenChange);\n document.addEventListener('msfullscreenchange', handleFullscreenChange);\n\n return () => {\n document.removeEventListener('fullscreenchange', handleFullscreenChange);\n document.removeEventListener('mozfullscreenchange', handleFullscreenChange);\n document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);\n document.removeEventListener('msfullscreenchange', handleFullscreenChange);\n };\n }, []);\n\n const onExpandClick = (_event) => {\n const element = document.querySelector('#complex-toolbar-demo');\n\n if (!isFullScreen) {\n if (element.requestFullscreen) {\n element.requestFullscreen();\n } else if (element.mozRequestFullScreen) {\n element.mozRequestFullScreen();\n } else if (element.webkitRequestFullScreen) {\n element.webkitRequestFullScreen(Element.ALLOW_KEYBOARD_INPUT);\n }\n setIsFullScreen(true);\n } else {\n if (document.exitFullscreen) {\n document.exitFullscreen();\n } else if (document.webkitExitFullscreen) {\n /* Safari */\n document.webkitExitFullscreen();\n } else if (document.msExitFullscreen) {\n /* IE11 */\n document.msExitFullscreen();\n }\n setIsFullScreen(false);\n }\n };\n\n const onDownloadClick = () => {\n const element = document.createElement('a');\n const dataToDownload = [data[dataSources[selectedDataSource].id]];\n const file = new Blob(dataToDownload, { type: 'text/plain' });\n element.href = URL.createObjectURL(file);\n element.download = `${selectedDataSource}.txt`;\n document.body.appendChild(element);\n element.click();\n document.body.removeChild(element);\n };\n\n const onToggleClick = () => {\n setSelectDataSourceOpen(!selectDataSourceOpen);\n };\n\n const onScroll = ({ scrollOffsetToBottom, _scrollDirection, scrollUpdateWasRequested }) => {\n if (!scrollUpdateWasRequested) {\n if (scrollOffsetToBottom > 0) {\n setIsPaused(true);\n } else {\n setIsPaused(false);\n }\n }\n };\n\n const selectDataSourceMenu = Object.entries(dataSources).map(([value, { type }]) => (\n \n {type}\n {` ${value}`}\n \n ));\n\n const selectDataSourcePlaceholder = (\n \n {dataSources[selectedDataSource].type}\n {` ${selectedDataSource}`}\n \n );\n\n const ControlButton = () => (\n {\n setIsPaused(!isPaused);\n }}\n icon={isPaused ? : }\n >\n {isPaused ? ` Resume log` : ` Pause log`}\n \n );\n\n const toggle = (toggleRef) => (\n \n {selectDataSourcePlaceholder}\n \n );\n\n const leftAlignedToolbarGroup = (\n \n } breakpoint=\"md\">\n \n setSelectDataSourceOpen(isOpen)}\n onSelect={(event, selection) => {\n setSelectDataSourceOpen(false);\n setSelectedDataSource(selection);\n setSelectedData(data[dataSources[selection].id].split('\\n'));\n setLinesBehind(0);\n setBuffer([]);\n setItemCount(1);\n setCurrentItemCount(0);\n }}\n selections={selectedDataSource}\n isOpen={selectDataSourceOpen}\n placeholderText={selectDataSourcePlaceholder}\n >\n {selectDataSourceMenu}\n \n \n \n setIsPaused(true)} placeholder=\"Search\" />\n \n \n \n \n \n \n );\n\n const rightAlignedToolbarGroup = (\n \n \n \n Download}>\n \n \n \n \n Expand}>\n \n \n \n \n \n );\n\n const FooterButton = () => {\n const handleClick = (_e) => {\n setIsPaused(false);\n };\n return (\n \n );\n };\n return (\n \n \n {leftAlignedToolbarGroup}\n {rightAlignedToolbarGroup}\n \n \n }\n overScanCount={10}\n footer={isPaused && }\n onScroll={onScroll}\n />\n );\n};\n","title":"With complex toolbar","lang":"js","className":""}}> 74 | 75 | 76 | }; 77 | 78 | const Component = () => ( 79 | 80 | {React.createElement(pageData.examples["With complex toolbar"])} 81 | 82 | ); 83 | Component.displayName = 'ExtensionsLogViewerReactDemosDocs'; 84 | Component.pageData = pageData; 85 | 86 | export default Component; 87 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/extensions/log-viewer/react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; 3 | import { useState, Fragment, useRef } from 'react'; 4 | import { LogViewer, LogViewerSearch, LogViewerContext } from '@patternfly/react-log-viewer'; 5 | import { Button, Checkbox, Toolbar, ToolbarContent, ToolbarItem } from '@patternfly/react-core'; 6 | import { data } from '@patternfly/react-log-viewer/patternfly-docs/content/extensions/react-log-viewer/examples/realTestData.js'; 7 | const pageData = { 8 | "id": "Log viewer", 9 | "section": "extensions", 10 | "subsection": "", 11 | "deprecated": false, 12 | "template": false, 13 | "beta": false, 14 | "demo": false, 15 | "newImplementationLink": false, 16 | "source": "react", 17 | "tabName": null, 18 | "slug": "/extensions/log-viewer/react", 19 | "sourceLink": "https://github.com/patternfly/react-log-viewer/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/basic.md", 20 | "relPath": "packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/basic.md", 21 | "propComponents": [ 22 | { 23 | "name": "LogViewer", 24 | "description": "", 25 | "props": [ 26 | { 27 | "name": "data", 28 | "type": "string | string[]", 29 | "description": "String or String Array data being sent by the consumer" 30 | }, 31 | { 32 | "name": "fastRowHeightEstimationLimit", 33 | "type": "number", 34 | "description": "The maximum char length for fast row height estimation.\nFor wrapped lines in Chrome based browsers, lines over this length will actually be rendered to the dom and\nmeasured to prevent a bug where one line can overlap another." 35 | }, 36 | { 37 | "name": "footer", 38 | "type": "React.ReactNode", 39 | "description": "Component rendered in the log viewer console window footer" 40 | }, 41 | { 42 | "name": "hasLineNumbers", 43 | "type": "boolean", 44 | "description": "Flag to enable or disable line numbers on the log viewer." 45 | }, 46 | { 47 | "name": "hasToolbar", 48 | "type": "boolean", 49 | "description": "Consumer may turn off the visibility on the toolbar" 50 | }, 51 | { 52 | "name": "header", 53 | "type": "React.ReactNode", 54 | "description": "Component rendered in the log viewer console window header" 55 | }, 56 | { 57 | "name": "height", 58 | "type": "number | string", 59 | "description": "Height of the log viewer." 60 | }, 61 | { 62 | "name": "initialIndexWidth", 63 | "type": "number", 64 | "description": "The width of index when the line numbers is shown, set by char numbers" 65 | }, 66 | { 67 | "name": "innerRef", 68 | "type": "React.RefObject", 69 | "description": "Forwarded ref" 70 | }, 71 | { 72 | "name": "isTextWrapped", 73 | "type": "boolean", 74 | "description": "Flag indicating that log viewer is wrapping text or not" 75 | }, 76 | { 77 | "name": "itemCount", 78 | "type": "number", 79 | "description": "Number of rows to display in the log viewer" 80 | }, 81 | { 82 | "name": "loadingContent", 83 | "type": "React.ReactNode", 84 | "description": "Content displayed while the log viewer is loading" 85 | }, 86 | { 87 | "name": "onScroll", 88 | "type": "({\n scrollDirection,\n scrollOffset,\n scrollOffsetToBottom,\n scrollUpdateWasRequested\n}: {\n scrollDirection: 'forward' | 'backward';\n scrollOffset: number;\n scrollOffsetToBottom: number;\n scrollUpdateWasRequested: boolean;\n}) => void", 89 | "description": "Callback function when scrolling the window.\nscrollDirection is the direction of scroll, could be 'forward'|'backward'.\nscrollOffset and scrollOffsetToBottom are the offset of the current position to the top or the bottom.\nscrollUpdateWasRequested is false when the scroll event is cause by the user interaction in the browser, else it's true.\n@example onScroll={({scrollDirection, scrollOffset, scrollOffsetToBottom, scrollUpdateWasRequested})=>{}}" 90 | }, 91 | { 92 | "name": "overScanCount", 93 | "type": "number", 94 | "description": "Rows rendered outside of view. The more rows are rendered, the higher impact on performance" 95 | }, 96 | { 97 | "name": "scrollToRow", 98 | "type": "number", 99 | "description": "Row index to scroll to" 100 | }, 101 | { 102 | "name": "theme", 103 | "type": "'dark' | 'light'", 104 | "description": "Flag indicating that log viewer is dark themed" 105 | }, 106 | { 107 | "name": "toolbar", 108 | "type": "React.ReactNode", 109 | "description": "Toolbar rendered in the log viewer header" 110 | }, 111 | { 112 | "name": "useAnsiClasses", 113 | "type": "boolean", 114 | "description": "Flag to enable or disable the use of classes (instead of inline styles) for ANSI coloring/formatting." 115 | }, 116 | { 117 | "name": "width", 118 | "type": "number | string", 119 | "description": "Width of the log viewer." 120 | } 121 | ] 122 | }, 123 | { 124 | "name": "LogViewerSearch", 125 | "description": "", 126 | "props": [ 127 | { 128 | "name": "minSearchChars", 129 | "type": "No type info", 130 | "defaultValue": "1" 131 | }, 132 | { 133 | "name": "placeholder", 134 | "type": "No type info", 135 | "defaultValue": "'Search'" 136 | } 137 | ] 138 | } 139 | ], 140 | "examples": [ 141 | "Basic", 142 | "With search", 143 | "With complex toolbar", 144 | "With header component", 145 | "With footer component", 146 | "With ANSI color logs" 147 | ] 148 | }; 149 | pageData.liveContext = { 150 | useState, 151 | Fragment, 152 | useRef, 153 | LogViewer, 154 | LogViewerSearch, 155 | LogViewerContext, 156 | Button, 157 | Checkbox, 158 | Toolbar, 159 | ToolbarContent, 160 | ToolbarItem, 161 | data 162 | }; 163 | pageData.examples = { 164 | 'Basic': props => 165 | {\n const [isDarkTheme, setIsDarkTheme] = useState(false);\n\n return (\n \n setIsDarkTheme(value)}\n aria-label=\"toggle dark theme checkbox\"\n id=\"toggle-dark-theme\"\n name=\"toggle-dark-theme\"\n />\n \n \n );\n};\n","title":"Basic","lang":"js","className":""}}> 166 | 167 | , 168 | 'With search': props => 169 | (\n \n \n \n \n \n \n \n }\n />\n);\n","title":"With search","lang":"js","className":""}}> 170 | 171 | , 172 | 'With complex toolbar': props => 173 | {\n const [isTextWrapped, setIsTextWrapped] = useState(false);\n const onActionClick = () => {\n console.log('clicked test action button');\n };\n\n const onPrintClick = () => {\n console.log('clicked console print button');\n };\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n setIsTextWrapped(value)}\n />\n \n \n \n }\n />\n );\n};\n","title":"With complex toolbar","lang":"js","className":""}}> 174 | 175 | , 176 | 'With header component': props => 177 | (\n 5019 lines} />\n);\n","title":"With header component","lang":"js","className":""}}> 178 | 179 | , 180 | 'With footer component': props => 181 | {\n const logViewerRef = useRef(null);\n const FooterButton = () => {\n const handleClick = () => {\n logViewerRef.current.scrollToBottom();\n };\n return ;\n };\n\n return (\n }\n />\n );\n};\n","title":"With footer component","lang":"js","className":""}}> 182 | 183 | , 184 | 'With ANSI color logs': props => 185 | (\n \n);\n","title":"With ANSI color logs","lang":"js","className":""}}> 186 | 187 | 188 | }; 189 | 190 | const Component = () => ( 191 | 192 | 193 | {`Examples`} 194 | 195 | {React.createElement(pageData.examples["Basic"])} 196 | {React.createElement(pageData.examples["With search"])} 197 | {React.createElement(pageData.examples["With complex toolbar"])} 198 | {React.createElement(pageData.examples["With header component"])} 199 | {React.createElement(pageData.examples["With footer component"])} 200 | {React.createElement(pageData.examples["With ANSI color logs"])} 201 | 202 | ); 203 | Component.displayName = 'ExtensionsLogViewerReactDocs'; 204 | Component.pageData = pageData; 205 | 206 | export default Component; 207 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/extensions/react-log-viewer/design-guidelines.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; 3 | 4 | const pageData = { 5 | "id": "react-log-viewer", 6 | "section": "extensions", 7 | "source": "design-guidelines", 8 | "slug": "/extensions/react-log-viewer/design-guidelines", 9 | "sourceLink": "https://github.com/patternfly/patternfly-org/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/design-guidelines/design-guidelines.md" 10 | }; 11 | pageData.relativeImports = { 12 | 13 | }; 14 | pageData.examples = { 15 | 16 | }; 17 | 18 | const Component = () => ( 19 | 20 |

21 | {`Design guidelines intro`} 22 |

23 | 24 | {`Header`} 25 | 26 | 27 | {`Sub-header`} 28 | 29 |

30 | {`Guidelines:`} 31 |

32 |
    33 |
  1. 34 | {`A`} 35 |
  2. 36 |
  3. 37 | {`list`} 38 |
  4. 39 |
  5. 40 | {`using`} 41 |
  6. 42 |
  7. 43 | {`markdown`} 44 |
  8. 45 |
46 |
47 | ); 48 | Component.displayName = 'ExtensionsReactLogViewerDesignGuidelinesDocs'; 49 | Component.pageData = pageData; 50 | 51 | export default Component; 52 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/extensions/react-log-viewer/react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; 3 | import { ExtendedButton } from "@patternfly/react-log-viewer"; 4 | const pageData = { 5 | "id": "react-log-viewer", 6 | "section": "extensions", 7 | "source": "react", 8 | "slug": "/extensions/react-log-viewer/react", 9 | "sourceLink": "https://github.com/patternfly/patternfly-react/blob/main/packages/module/patternfly-docs/content/extensions/react-log-viewer/examples/basic.md", 10 | "propComponents": [ 11 | { 12 | "name": "ExtendedButton", 13 | "description": "", 14 | "props": [ 15 | { 16 | "name": "children", 17 | "type": "React.ReactNode", 18 | "description": "Content to render inside the extended button component" 19 | } 20 | ] 21 | } 22 | ], 23 | "examples": [ 24 | "Example" 25 | ], 26 | "fullscreenExamples": [ 27 | "Fullscreen example" 28 | ] 29 | }; 30 | pageData.liveContext = { 31 | ExtendedButton 32 | }; 33 | pageData.relativeImports = { 34 | 35 | }; 36 | pageData.examples = { 37 | 'Example': props => 38 | My custom extension button;\n","title":"Example","lang":"js"}}> 39 | 40 | , 41 | 'Fullscreen example': props => 42 | My custom extension button;\n","title":"Fullscreen example","lang":"js","isFullscreen":true}}> 43 | 44 | 45 | }; 46 | 47 | const Component = () => ( 48 | 49 | 50 | {`Basic usage`} 51 | 52 | {React.createElement(pageData.examples["Example"])} 53 | {React.createElement(pageData.examples["Fullscreen example"])} 54 | 55 | ); 56 | Component.displayName = 'ExtensionsPatternflyExtensionSeedReactDocs'; 57 | Component.pageData = pageData; 58 | 59 | export default Component; 60 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/generated/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '/extensions/log-viewer/react': { 3 | id: "Log viewer", 4 | title: "Log viewer", 5 | toc: [{"text":"Examples"},[{"text":"Basic"},{"text":"With search"},{"text":"With complex toolbar"},{"text":"With header component"},{"text":"With footer component"},{"text":"With ANSI color logs"}]], 6 | examples: ["Basic","With search","With complex toolbar","With header component","With footer component","With ANSI color logs"], 7 | section: "extensions", 8 | subsection: "", 9 | source: "react", 10 | tabName: null, 11 | Component: () => import(/* webpackChunkName: "extensions/log-viewer/react/index" */ './extensions/log-viewer/react') 12 | }, 13 | '/extensions/log-viewer/design-guidelines': { 14 | id: "Log viewer", 15 | title: "Log viewer", 16 | toc: [{"text":"Light theme log viewer"},{"text":"Dark theme log viewer"},{"text":"Usability"},{"text":"Log viewer functionality"},[{"text":"With popover"},{"text":"With dropdown, drilldown, and search expanded"},{"text":"With search results"}]], 17 | section: "extensions", 18 | subsection: "", 19 | source: "design-guidelines", 20 | tabName: null, 21 | Component: () => import(/* webpackChunkName: "extensions/log-viewer/design-guidelines/index" */ './extensions/log-viewer/design-guidelines') 22 | }, 23 | '/extensions/log-viewer/react-demos': { 24 | id: "Log viewer", 25 | title: "Log viewer", 26 | toc: [[{"text":"With complex toolbar"}]], 27 | examples: ["With complex toolbar"], 28 | section: "extensions", 29 | subsection: "", 30 | source: "react-demos", 31 | tabName: null, 32 | Component: () => import(/* webpackChunkName: "extensions/log-viewer/react-demos/index" */ './extensions/log-viewer/react-demos') 33 | } 34 | }; -------------------------------------------------------------------------------- /packages/module/patternfly-docs/pages/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import React from 'react'; 3 | import { Title, PageSection } from '@patternfly/react-core'; 4 | 5 | const centerStyle = { 6 | flexGrow: 1, 7 | display: 'flex', 8 | alignItems: 'center', 9 | justifyContent: 'center' 10 | }; 11 | 12 | const IndexPage = () => { 13 | return ( 14 | 15 |
16 | 17 | My extension docs 18 | 19 | 20 | {'Hi people!'} 21 | 22 |

Welcome to my extension docs.

23 |
24 |
25 | ); 26 | }; 27 | 28 | export default IndexPage; 29 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/patternfly-docs.config.js: -------------------------------------------------------------------------------- 1 | // This module is shared between NodeJS and babelled ES5 2 | module.exports = { 3 | sideNavItems: [{ section: 'extensions' }], 4 | topNavItems: [], 5 | port: 8006 6 | }; 7 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/patternfly-docs.css.js: -------------------------------------------------------------------------------- 1 | // Patternfly 2 | import '@patternfly/patternfly/patternfly.css'; 3 | // Patternfly utilities 4 | import '@patternfly/patternfly/patternfly-addons.css'; 5 | // Global theme CSS 6 | import '@patternfly/documentation-framework/global.css'; 7 | 8 | // Add your extension CSS below 9 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/patternfly-docs.routes.js: -------------------------------------------------------------------------------- 1 | // This module is shared between NodeJS and babelled ES5 2 | const isClient = Boolean(process.env.NODE_ENV); 3 | 4 | module.exports = { 5 | '/': { 6 | SyncComponent: isClient && require('./pages/index').default 7 | }, 8 | '/404': { 9 | SyncComponent: isClient && require('@patternfly/documentation-framework/pages/404/index').default, 10 | title: '404 Error' 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /packages/module/patternfly-docs/patternfly-docs.source.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = (sourceMD, sourceProps) => { 4 | // Parse source content for props so that we can display them 5 | const propsIgnore = ['**/*.test.tsx', '**/examples/*.tsx']; 6 | const extensionPath = path.join(__dirname, '../src'); 7 | sourceProps(path.join(extensionPath, '/**/*.tsx'), propsIgnore); 8 | 9 | // Parse md files 10 | const contentBase = path.join(__dirname, './content'); 11 | sourceMD(path.join(contentBase, 'extensions/**/*.md'), 'extensions'); 12 | 13 | /** 14 | If you want to parse content from node_modules instead of providing a relative/absolute path, 15 | you can do something similar to this: 16 | const extensionPath = require 17 | .resolve('@patternfly/react-log-viewer/package.json') 18 | .replace('package.json', 'src'); 19 | sourceProps(path.join(extensionPath, '/**\/*.tsx'), propsIgnore); 20 | sourceMD(path.join(extensionPath, '../patternfly-docs/**\/examples/*.md'), 'react'); 21 | sourceMD(path.join(extensionPath, '../patternfly-docs/**\/demos/*.md'), 'react-demos'); 22 | sourceMD(path.join(extensionPath, '../patternfly-docs/**\/design-guidelines/*.md'), 'design-guidelines'); 23 | */ 24 | }; 25 | -------------------------------------------------------------------------------- /packages/module/release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: [ 3 | 'do-not-delete', 4 | { name: 'main', channel: 'prerelease', prerelease: 'prerelease' } 5 | ], 6 | analyzeCommits: { 7 | preset: 'angular' 8 | }, 9 | plugins: [ 10 | [ 11 | '@semantic-release/commit-analyzer', 12 | { 13 | preset: 'angular', 14 | releaseRules: [ 15 | { type: 'chore', scope: 'deps', release: 'patch' }, 16 | { type: 'chore', scope: 'ci-release', release: 'patch' } 17 | ] 18 | } 19 | ], 20 | '@semantic-release/release-notes-generator', 21 | '@semantic-release/github', 22 | '@semantic-release/npm' 23 | ], 24 | tagFormat: 'prerelease-v${version}', 25 | dryRun: false 26 | }; 27 | -------------------------------------------------------------------------------- /packages/module/scripts/generateClassMaps.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs-extra'); 3 | const glob = require('glob'); 4 | const camelcase = require('camel-case'); 5 | 6 | /** 7 | * @param {string} cssString - CSS string 8 | */ 9 | function getCSSClasses(cssString) { 10 | return cssString.match(/(\.)(?!\d)([^\s.,{[>+~#:)]*)(?![^{]*})/g); 11 | } 12 | 13 | /** 14 | * @param {string} className - Class name 15 | */ 16 | function formatClassName(className) { 17 | return camelcase(className.replace(/pf-(v6-)?((c|l|m|u|is|has)-)?/g, '')); 18 | } 19 | 20 | /** 21 | * @param {string} className - Class name 22 | */ 23 | function isModifier(className) { 24 | return Boolean(className && className.startsWith) && className.startsWith('.pf-m-'); 25 | } 26 | 27 | /** 28 | * @param {string} cssString - CSS string 29 | */ 30 | function getClassMaps(cssString) { 31 | const res = {}; 32 | const distinctClasses = new Set(getCSSClasses(cssString)); 33 | 34 | distinctClasses.forEach((className) => { 35 | const key = formatClassName(className); 36 | const value = className.replace('.', '').trim(); 37 | if (isModifier(className)) { 38 | res.modifiers = res.modifiers || {}; 39 | res.modifiers[key] = value; 40 | } else { 41 | res[key] = value; 42 | } 43 | }); 44 | 45 | const ordered = {}; 46 | Object.keys(res) 47 | .sort() 48 | .forEach((key) => (ordered[key] = res[key])); 49 | 50 | return ordered; 51 | } 52 | 53 | /** 54 | * @returns {any} Map of file names to classMaps 55 | */ 56 | function generateClassMaps() { 57 | const cssFiles = glob.sync('src/**/*.css', { 58 | absolute: true 59 | }); 60 | 61 | const res = {}; 62 | cssFiles 63 | .map((file) => path.resolve(file)) // Normalize path for Windows 64 | .forEach((file) => { 65 | res[file] = getClassMaps(fs.readFileSync(file, 'utf8')); 66 | }); 67 | 68 | return res; 69 | } 70 | 71 | module.exports = { 72 | generateClassMaps 73 | }; 74 | -------------------------------------------------------------------------------- /packages/module/scripts/writeClassMaps.js: -------------------------------------------------------------------------------- 1 | const { join, basename, relative, dirname } = require('path'); 2 | const { outputFileSync, copyFileSync, ensureDirSync } = require('fs-extra'); 3 | const { generateClassMaps } = require('./generateClassMaps'); 4 | 5 | const writeTsExport = (file, classMap, outDir) => 6 | outputFileSync( 7 | join(outDir, file.replace(/.css$/, '.ts')), 8 | ` 9 | import './${basename(file, '.css.js')}'; 10 | export default ${JSON.stringify(classMap, null, 2)}; 11 | `.trim() 12 | ); 13 | 14 | /** 15 | * @param {any} classMaps Map of file names to classMaps 16 | */ 17 | function writeClassMaps(classMaps) { 18 | Object.entries(classMaps).forEach(([file, classMap]) => { 19 | const packageBase = dirname(require.resolve('@patternfly/react-log-viewer/package.json')); 20 | const relativeFilePath = relative(packageBase, file); 21 | 22 | // write the export map in TS and put it in src, from here TS will compile it to the different module types at build time 23 | writeTsExport(relativeFilePath, classMap, packageBase); 24 | 25 | // copy the css file itself over to dist so that they can be easily imported since TS won't do that 26 | const cssFileName = basename(file); 27 | const distDir = join(packageBase, 'dist'); 28 | const cssDistDir = join(distDir, 'css'); 29 | ensureDirSync(cssDistDir); 30 | copyFileSync(file, join(cssDistDir, cssFileName)); 31 | 32 | // create css files for each exported module to reference since TS won't do that either 33 | const fileDir = dirname(relativeFilePath).replace('src/', ''); 34 | const cssDistEsmDir = join(distDir, 'esm', fileDir); 35 | const cssDistCjsDir = join(distDir, 'js', fileDir); 36 | const cssDistDirs = [cssDistEsmDir, cssDistCjsDir]; 37 | cssDistDirs.forEach((dir) => { 38 | ensureDirSync(dir); 39 | copyFileSync(file, join(dir, cssFileName)); 40 | }); 41 | }); 42 | 43 | // eslint-disable-next-line no-console 44 | console.log('Wrote', Object.keys(classMaps).length * 3, 'CSS-in-JS files'); 45 | } 46 | 47 | writeClassMaps(generateClassMaps()); 48 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/LogViewer.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, memo, useMemo, useRef, forwardRef } from 'react'; 2 | import { LogViewerContext, LogViewerToolbarContext } from './LogViewerContext'; 3 | import { css } from '@patternfly/react-styles'; 4 | import { LogViewerRow } from './LogViewerRow'; 5 | import { parseConsoleOutput, searchedKeyWordType, stripAnsi } from './utils/utils'; 6 | import { VariableSizeList as List, areEqual } from '../react-window'; 7 | import styles from './css/log-viewer'; 8 | import AnsiUp from '../ansi_up/ansi_up'; 9 | 10 | export interface LogViewerProps { 11 | /** String or String Array data being sent by the consumer*/ 12 | data?: string | string[]; 13 | /** Consumer may turn off the visibility on the toolbar */ 14 | hasToolbar?: boolean; 15 | /** Flag to enable or disable line numbers on the log viewer. */ 16 | hasLineNumbers?: boolean; 17 | /** Width of the log viewer. */ 18 | width?: number | string; 19 | /** Height of the log viewer. */ 20 | height?: number | string; 21 | /** Rows rendered outside of view. The more rows are rendered, the higher impact on performance */ 22 | overScanCount?: number; 23 | /** Toolbar rendered in the log viewer header */ 24 | toolbar?: React.ReactNode; 25 | /** Content displayed while the log viewer is loading */ 26 | loadingContent?: React.ReactNode; 27 | /** Flag indicating that log viewer is dark themed */ 28 | theme?: 'dark' | 'light'; 29 | /** Row index to scroll to */ 30 | scrollToRow?: number; 31 | /** The width of index when the line numbers is shown, set by char numbers */ 32 | initialIndexWidth?: number; 33 | /** Number of rows to display in the log viewer */ 34 | itemCount?: number; 35 | /** Flag indicating that log viewer is wrapping text or not */ 36 | isTextWrapped?: boolean; 37 | /** Component rendered in the log viewer console window header */ 38 | header?: React.ReactNode; 39 | /** Component rendered in the log viewer console window footer */ 40 | footer?: React.ReactNode; 41 | /** Callback function when scrolling the window. 42 | * scrollDirection is the direction of scroll, could be 'forward'|'backward'. 43 | * scrollOffset and scrollOffsetToBottom are the offset of the current position to the top or the bottom. 44 | * scrollUpdateWasRequested is false when the scroll event is cause by the user interaction in the browser, else it's true. 45 | * @example onScroll={({scrollDirection, scrollOffset, scrollOffsetToBottom, scrollUpdateWasRequested})=>{}} 46 | */ 47 | onScroll?: ({ 48 | scrollDirection, 49 | scrollOffset, 50 | scrollOffsetToBottom, 51 | scrollUpdateWasRequested 52 | }: { 53 | scrollDirection: 'forward' | 'backward'; 54 | scrollOffset: number; 55 | scrollOffsetToBottom: number; 56 | scrollUpdateWasRequested: boolean; 57 | }) => void; 58 | /** Forwarded ref */ 59 | innerRef?: React.RefObject; 60 | /** Flag to enable or disable the use of classes (instead of inline styles) for ANSI coloring/formatting. */ 61 | useAnsiClasses?: boolean; 62 | /** The maximum char length for fast row height estimation. 63 | * For wrapped lines in Chrome based browsers, lines over this length will actually be rendered to the dom and 64 | * measured to prevent a bug where one line can overlap another. 65 | */ 66 | fastRowHeightEstimationLimit?: number; 67 | } 68 | 69 | let canvas: HTMLCanvasElement | undefined; 70 | 71 | const getCharNums = (windowWidth: number, font: string) => { 72 | // if given, use cached canvas for better performance 73 | // else, create new canvas 74 | canvas = canvas || document.createElement('canvas'); 75 | const context = canvas.getContext('2d'); 76 | context.font = font; 77 | const oneChar = context.measureText('a'); 78 | return Math.floor(windowWidth / oneChar.width); 79 | }; 80 | 81 | const LogViewerBase: React.FunctionComponent = memo( 82 | ({ 83 | data = '', 84 | hasLineNumbers = true, 85 | height = 600, 86 | overScanCount = 10, 87 | loadingContent = '', 88 | toolbar, 89 | width, 90 | theme = 'light', 91 | scrollToRow = 0, 92 | itemCount = undefined, 93 | header, 94 | footer, 95 | onScroll, 96 | innerRef, 97 | isTextWrapped = true, 98 | initialIndexWidth, 99 | useAnsiClasses, 100 | fastRowHeightEstimationLimit = 5000, 101 | ...props 102 | }: LogViewerProps) => { 103 | const [searchedInput, setSearchedInput] = useState(''); 104 | const [rowInFocus, setRowInFocus] = useState({ rowIndex: scrollToRow, matchIndex: 0 }); 105 | const [searchedWordIndexes, setSearchedWordIndexes] = useState([]); 106 | const [currentSearchedItemCount, setCurrentSearchedItemCount] = useState(0); 107 | const [lineHeight, setLineHeight] = useState(0); 108 | const [charNumsPerLine, setCharNumsPerLine] = useState(0); 109 | const [indexWidth, setIndexWidth] = useState(0); 110 | const [resizing, setResizing] = useState(false); 111 | const [loading, setLoading] = useState(true); 112 | const [listKey, setListKey] = useState(1); 113 | 114 | /* Parse data every time it changes */ 115 | const parsedData = useMemo(() => parseConsoleOutput(data), [data]); 116 | 117 | const isChrome = useMemo(() => navigator.userAgent.indexOf('Chrome') !== -1, []); 118 | 119 | const ansiUp = new AnsiUp(); 120 | // eslint-disable-next-line camelcase 121 | ansiUp.escape_html = false; 122 | // eslint-disable-next-line camelcase 123 | ansiUp.use_classes = useAnsiClasses; 124 | 125 | const ref = useRef(null); 126 | const logViewerRef = innerRef || ref; 127 | const containerRef = useRef(null); 128 | let resizeTimer = null as any; 129 | 130 | useEffect(() => { 131 | if (containerRef && containerRef.current) { 132 | window.addEventListener('resize', callbackResize); 133 | setLoading(false); 134 | createDummyElements(); 135 | ansiUp.resetStyles(); 136 | } 137 | return () => window.removeEventListener('resize', callbackResize); 138 | }, [containerRef.current]); 139 | 140 | const callbackResize = () => { 141 | if (!resizing) { 142 | setResizing(true); 143 | } 144 | if (resizeTimer) { 145 | clearTimeout(resizeTimer); 146 | } 147 | resizeTimer = setTimeout(() => { 148 | setResizing(false); 149 | createDummyElements(); 150 | }, 100); 151 | }; 152 | 153 | useEffect(() => { 154 | setLoading(resizing); 155 | }, [resizing]); 156 | 157 | const dataToRender = useMemo( 158 | () => ({ 159 | parsedData, 160 | logViewerRef, 161 | rowInFocus, 162 | searchedWordIndexes 163 | }), 164 | [parsedData, logViewerRef, rowInFocus, searchedWordIndexes] 165 | ); 166 | 167 | useEffect(() => { 168 | if (logViewerRef && logViewerRef.current) { 169 | logViewerRef.current.resetAfterIndex(0); 170 | } 171 | }, [parsedData]); 172 | 173 | useEffect(() => { 174 | if (scrollToRow && parsedData.length) { 175 | setRowInFocus({ rowIndex: scrollToRow, matchIndex: 0 }); 176 | // only in this way (setTimeout) the scrollToItem will work 177 | setTimeout(() => { 178 | if (logViewerRef && logViewerRef.current) { 179 | logViewerRef.current.scrollToItem(scrollToRow, 'center'); 180 | } 181 | }, 1); 182 | } 183 | }, [parsedData, scrollToRow]); 184 | 185 | const createDummyElements = () => { 186 | // create dummy elements 187 | const dummyIndex = document.createElement('span'); 188 | dummyIndex.className = css(styles.logViewerIndex); 189 | const dummyText = document.createElement('span'); 190 | dummyText.className = css(styles.logViewerText); 191 | const dummyListItem = document.createElement('div'); 192 | dummyListItem.className = css(styles.logViewerListItem); 193 | const dummyList = document.createElement('div'); 194 | dummyList.className = css(styles.logViewerList); 195 | // append dummy elements 196 | dummyListItem.appendChild(dummyIndex); 197 | dummyListItem.appendChild(dummyText); 198 | dummyList.appendChild(dummyListItem); 199 | containerRef.current.appendChild(dummyList); 200 | // compute styles 201 | const dummyIndexStyles = getComputedStyle(dummyIndex); 202 | const dummyTextStyles = getComputedStyle(dummyText); 203 | setLineHeight(parseFloat(dummyTextStyles.lineHeight)); 204 | const lineWidth = hasLineNumbers 205 | ? (containerRef.current as HTMLDivElement).clientWidth - 206 | (parseFloat(dummyTextStyles.paddingLeft) + 207 | parseFloat(dummyTextStyles.paddingRight) + 208 | parseFloat(dummyIndexStyles.width)) 209 | : (containerRef.current as HTMLDivElement).clientWidth - 210 | (parseFloat(dummyTextStyles.paddingLeft) + parseFloat(dummyTextStyles.paddingRight)); 211 | const charNumsPerLine = getCharNums( 212 | lineWidth, 213 | `${dummyTextStyles.fontWeight} ${dummyTextStyles.fontSize} ${dummyTextStyles.fontFamily}` 214 | ); 215 | setCharNumsPerLine(charNumsPerLine); 216 | setIndexWidth(parseFloat(dummyIndexStyles.width)); 217 | // remove dummy elements from the DOM tree 218 | containerRef.current.removeChild(dummyList); 219 | setListKey((listKey) => listKey + 1); 220 | }; 221 | 222 | const scrollToRowInFocus = (searchedRowIndex: searchedKeyWordType) => { 223 | setRowInFocus(searchedRowIndex); 224 | logViewerRef.current.scrollToItem(searchedRowIndex.rowIndex, 'center'); 225 | // use this method to scroll to the right 226 | // if the keyword is out of the window when wrapping text 227 | if (!isTextWrapped) { 228 | setTimeout(() => { 229 | const element = containerRef.current.querySelector('.pf-v6-c-log-viewer__string.pf-m-current'); 230 | element && element.scrollIntoView({ block: 'nearest', inline: 'center' }); 231 | }, 1); 232 | } 233 | }; 234 | 235 | useEffect(() => { 236 | setListKey((listKey) => listKey + 1); 237 | }, [isTextWrapped]); 238 | 239 | const computeRowHeight = (rowText: string, estimatedHeight: number) => { 240 | const logViewerList = containerRef.current.firstChild.firstChild; 241 | 242 | // early return with the estimated height if the log viewer list hasn't been rendered yet, 243 | // this will be called again once it has been rendered and the correct height will be set 244 | if (!logViewerList) { 245 | return estimatedHeight; 246 | } 247 | 248 | const dummyText = document.createElement('span'); 249 | dummyText.className = css(styles.logViewerText); 250 | dummyText.innerHTML = rowText; 251 | 252 | logViewerList.appendChild(dummyText); 253 | const computedHeight = dummyText.clientHeight; 254 | logViewerList.removeChild(dummyText); 255 | 256 | return computedHeight; 257 | }; 258 | 259 | const guessRowHeight = (rowIndex: number) => { 260 | if (!isTextWrapped) { 261 | return lineHeight; 262 | } 263 | // strip ansi escape code before estimate the row height 264 | const rowText = stripAnsi(parsedData[rowIndex]); 265 | // get the row numbers of the current text 266 | const numRows = Math.ceil(rowText.length / charNumsPerLine); 267 | // multiply by line height to get the total height 268 | const heightGuess = lineHeight * (numRows || 1); 269 | 270 | // because of a bug in react-window (which seems to be limited to chrome) we need to 271 | // actually compute row height in long lines to prevent them from overflowing. 272 | // related issue https://github.com/bvaughn/react-window/issues/593 273 | if (rowText.length > fastRowHeightEstimationLimit && isChrome && isTextWrapped) { 274 | return computeRowHeight(rowText, heightGuess); 275 | } 276 | 277 | return heightGuess; 278 | }; 279 | 280 | const createList = (parsedData: string[]) => ( 281 | 298 | {LogViewerRow} 299 | 300 | ); 301 | 302 | return ( 303 | 309 |
324 | {toolbar && ( 325 | 339 |
{toolbar}
340 |
341 | )} 342 | {header} 343 |
344 | {loading ?
{loadingContent}
: createList(parsedData)} 345 |
346 | {footer} 347 |
348 |
349 | ); 350 | }, 351 | areEqual 352 | ); 353 | 354 | export const LogViewer = forwardRef((props: LogViewerProps, ref: React.Ref) => ( 355 | } {...props} /> 356 | )); 357 | 358 | LogViewer.displayName = 'LogViewer'; 359 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/LogViewerContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { searchedKeyWordType } from './utils/utils'; 3 | 4 | export const useLogViewerContext = () => useContext(LogViewerContext); 5 | 6 | interface LogViewerContextInterface { 7 | parsedData: string[]; 8 | searchedInput: string; 9 | } 10 | 11 | export const LogViewerContext = createContext(null); 12 | 13 | interface LogViewerToolbarContextProps { 14 | searchedWordIndexes: searchedKeyWordType[]; 15 | rowInFocus: searchedKeyWordType; 16 | searchedInput: string; 17 | itemCount: number; 18 | currentSearchedItemCount: number; 19 | scrollToRow: (searchedRow: searchedKeyWordType) => void; 20 | setRowInFocus: (index: searchedKeyWordType) => void; 21 | setSearchedInput: (input: string) => void; 22 | setSearchedWordIndexes: (indexes: searchedKeyWordType[]) => void; 23 | setCurrentSearchedItemCount: (index: number) => void; 24 | } 25 | 26 | export const LogViewerToolbarContext = createContext(null); 27 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/LogViewerRow.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import { LOGGER_LINE_NUMBER_INDEX_DELTA } from './utils/constants'; 3 | import { css } from '@patternfly/react-styles'; 4 | import styles from './css/log-viewer'; 5 | import { LogViewerContext } from './LogViewerContext'; 6 | import AnsiUp from '../ansi_up/ansi_up'; 7 | import { escapeString, escapeTextForHtml, isAnsi, searchedKeyWordType, splitAnsi } from './utils/utils'; 8 | 9 | interface LogViewerRowProps { 10 | index: number; 11 | style?: React.CSSProperties; 12 | data: { 13 | parsedData: string[] | null; 14 | rowInFocus: searchedKeyWordType; 15 | searchedWordIndexes: searchedKeyWordType[]; 16 | }; 17 | ansiUp: AnsiUp; 18 | } 19 | 20 | export const LogViewerRow: React.FunctionComponent = memo(({ index, style, data, ansiUp }) => { 21 | const { parsedData, searchedWordIndexes, rowInFocus } = data; 22 | const context = useContext(LogViewerContext); 23 | 24 | const getData = (index: number): string => (parsedData ? parsedData[index] : null); 25 | 26 | const getRowIndex = (index: number): number => index + LOGGER_LINE_NUMBER_INDEX_DELTA; 27 | 28 | /** Helper function for applying the correct styling for styling rows containing searched keywords */ 29 | const handleHighlight = (matchCounter: number) => { 30 | const searchedWordResult = searchedWordIndexes.filter((searchedWord) => searchedWord.rowIndex === index); 31 | if (searchedWordResult.length !== 0) { 32 | if (rowInFocus.rowIndex === index && rowInFocus.matchIndex === matchCounter) { 33 | return styles.modifiers.current; 34 | } 35 | return styles.modifiers.match; 36 | } 37 | return ''; 38 | }; 39 | 40 | const getFormattedData = () => { 41 | const rowText = getData(index); 42 | let matchCounter = 0; 43 | if (context.searchedInput) { 44 | const splitAnsiString = splitAnsi(rowText); 45 | const regEx = new RegExp(`(${escapeString(context.searchedInput)})`, 'ig'); 46 | const composedString: string[] = []; 47 | splitAnsiString.forEach((str) => { 48 | matchCounter = 0; 49 | if (isAnsi(str)) { 50 | composedString.push(str); 51 | } else { 52 | const splitString = str.split(regEx); 53 | splitString.forEach((substr) => { 54 | if (substr.match(regEx)) { 55 | matchCounter += 1; 56 | composedString.push( 57 | `${substr}` 58 | ); 59 | } else { 60 | composedString.push(escapeTextForHtml(substr)); 61 | } 62 | }); 63 | } 64 | }); 65 | return composedString.join(''); 66 | } 67 | return escapeTextForHtml(rowText); 68 | }; 69 | 70 | return ( 71 |
72 | {getRowIndex(index)} 73 | 78 |
79 | ); 80 | }); 81 | LogViewerRow.displayName = 'LogViewerRow'; 82 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/LogViewerSearch.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react'; 2 | import { 3 | NUMBER_INDEX_DELTA, 4 | DEFAULT_FOCUS, 5 | DEFAULT_INDEX, 6 | DEFAULT_SEARCH_INDEX, 7 | DEFAULT_MATCH 8 | } from './utils/constants'; 9 | import { SearchInput, SearchInputProps } from '@patternfly/react-core'; 10 | import { LogViewerToolbarContext, LogViewerContext } from './LogViewerContext'; 11 | import { escapeString, searchForKeyword, searchedKeyWordType } from './utils/utils'; 12 | 13 | export interface LogViewerSearchProps extends SearchInputProps { 14 | /** Place holder text inside of searchbar */ 15 | placeholder: string; 16 | /** Minimum number of characters required for searching */ 17 | minSearchChars: number; 18 | } 19 | 20 | export const LogViewerSearch: React.FunctionComponent = ({ 21 | placeholder = 'Search', 22 | minSearchChars = 1, 23 | ...props 24 | }) => { 25 | const [indexAdjuster, setIndexAdjuster] = useState(0); 26 | const { 27 | searchedWordIndexes, 28 | scrollToRow, 29 | setSearchedInput, 30 | setCurrentSearchedItemCount, 31 | setRowInFocus, 32 | setSearchedWordIndexes, 33 | currentSearchedItemCount, 34 | searchedInput, 35 | itemCount 36 | } = useContext(LogViewerToolbarContext); 37 | 38 | const { parsedData } = useContext(LogViewerContext); 39 | 40 | const defaultRowInFocus = { rowIndex: DEFAULT_FOCUS, matchIndex: DEFAULT_MATCH }; 41 | 42 | /* Defaulting the first focused row that contain searched keywords */ 43 | useEffect(() => { 44 | if (hasFoundResults) { 45 | setIndexAdjuster(1); 46 | } else { 47 | setIndexAdjuster(0); 48 | } 49 | }, [searchedWordIndexes]); 50 | 51 | /* Updating searchedResults context state given changes in searched input */ 52 | useEffect(() => { 53 | let foundKeywordIndexes: (searchedKeyWordType | null)[] = []; 54 | const adjustedSearchedInput = escapeString(searchedInput); 55 | 56 | if (adjustedSearchedInput !== '' && adjustedSearchedInput.length >= minSearchChars) { 57 | foundKeywordIndexes = searchForKeyword(adjustedSearchedInput, parsedData, itemCount || parsedData.length); 58 | 59 | if (foundKeywordIndexes.length !== 0) { 60 | setSearchedWordIndexes(foundKeywordIndexes); 61 | scrollToRow(foundKeywordIndexes[DEFAULT_SEARCH_INDEX]); 62 | setCurrentSearchedItemCount(DEFAULT_INDEX); 63 | } 64 | } 65 | 66 | if (!adjustedSearchedInput) { 67 | setRowInFocus(defaultRowInFocus); 68 | } 69 | }, [searchedInput]); 70 | 71 | const hasFoundResults = searchedWordIndexes.length > 0 && searchedWordIndexes[0]?.rowIndex !== -1; 72 | 73 | /* Clearing out the search input */ 74 | const handleClear = (): void => { 75 | setSearchedInput(''); 76 | setCurrentSearchedItemCount(DEFAULT_INDEX); 77 | setSearchedWordIndexes([]); 78 | setRowInFocus(defaultRowInFocus); 79 | }; 80 | 81 | /* Moving focus over to next row containing searched word */ 82 | const handleNextSearchItem = (): void => { 83 | const adjustedSearchedItemCount = (currentSearchedItemCount + NUMBER_INDEX_DELTA) % searchedWordIndexes.length; 84 | 85 | setCurrentSearchedItemCount(adjustedSearchedItemCount); 86 | scrollToRow(searchedWordIndexes[adjustedSearchedItemCount]); 87 | }; 88 | 89 | /* Moving focus over to next row containing searched word */ 90 | const handlePrevSearchItem = (): void => { 91 | let adjustedSearchedItemCount = currentSearchedItemCount - NUMBER_INDEX_DELTA; 92 | 93 | if (adjustedSearchedItemCount < DEFAULT_INDEX) { 94 | adjustedSearchedItemCount += searchedWordIndexes.length; 95 | } 96 | 97 | setCurrentSearchedItemCount(adjustedSearchedItemCount); 98 | scrollToRow(searchedWordIndexes[adjustedSearchedItemCount]); 99 | }; 100 | 101 | return ( 102 | { 108 | props.onChange && props.onChange(event, input); 109 | setSearchedInput(input); 110 | }} 111 | onNextClick={(event) => { 112 | props.onNextClick && props.onNextClick(event); 113 | handleNextSearchItem(); 114 | }} 115 | onPreviousClick={(event) => { 116 | props.onPreviousClick && props.onPreviousClick(event); 117 | handlePrevSearchItem(); 118 | }} 119 | onClear={(event) => { 120 | props.onClear && props.onClear(event); 121 | handleClear(); 122 | }} 123 | /> 124 | ); 125 | }; 126 | LogViewerSearch.displayName = 'LogViewerSearch'; 127 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/__test__/Logviewer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import '@testing-library/jest-dom'; 3 | import 'jest-canvas-mock'; 4 | import { LogViewer } from '../LogViewer'; 5 | import { data } from './realTestData'; 6 | 7 | test('Renders without children', () => { 8 | render( 9 |
10 | 11 |
12 | ); 13 | 14 | expect(screen.getByTestId('container').firstChild).toBeVisible(); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/css/log-viewer.css: -------------------------------------------------------------------------------- 1 | .pf-v6-c-log-viewer { 2 | --pf-v6-c-log-viewer--Height: 100%; 3 | --pf-v6-c-log-viewer--MaxHeight: auto; 4 | --pf-v6-c-log-viewer--m-line-numbers__index--Display: inline; 5 | --pf-v6-c-log-viewer__header--MarginBlockEnd: var(--pf-t--global--spacer--sm); 6 | --pf-v6-c-log-viewer__main--BackgroundColor: var(--pf-t--global--background--color--primary--default); 7 | --pf-v6-c-log-viewer__main--BorderWidth: var(--pf-t--global--border--width--box--default); 8 | --pf-v6-c-log-viewer__main--BorderColor: var(--pf-t--global--border--color--default); 9 | --pf-v6-c-log-viewer__scroll-container--Height: 37.5rem; 10 | --pf-v6-c-log-viewer__scroll-container--PaddingBlockStart: var(--pf-t--global--spacer--sm); 11 | --pf-v6-c-log-viewer__scroll-container--PaddingBlockEnd: var(--pf-t--global--spacer--sm); 12 | --pf-v6-c-log-viewer--m-line-numbers__list--before--InsetBlockStart: 0; 13 | --pf-v6-c-log-viewer--m-line-numbers__list--before--InsetBlockEnd: 0; 14 | --pf-v6-c-log-viewer--m-line-numbers__list--before--Width: var(--pf-t--global--border--width--divider--default); 15 | --pf-v6-c-log-viewer--m-line-numbers__list--before--BackgroundColor: var(--pf-t--global--border--color--default); 16 | --pf-v6-c-log-viewer__list--Height: auto; 17 | --pf-v6-c-log-viewer--m-line-numbers__list--InsetInlineStart: var(--pf-v6-c-log-viewer__index--Width); 18 | --pf-v6-c-log-viewer__list--FontFamily: var(--pf-t--global--font--family--mono); 19 | --pf-v6-c-log-viewer__list--FontSize: var(--pf-t--global--font--size--body--sm); 20 | --pf-v6-c-log-viewer__index--Display: none; 21 | --pf-v6-c-log-viewer__index--Width: 4.0625rem; 22 | --pf-v6-c-log-viewer__index--PaddingInlineEnd: var(--pf-t--global--spacer--xl); 23 | --pf-v6-c-log-viewer__index--PaddingInlineStart: var(--pf-t--global--spacer--lg); 24 | --pf-v6-c-log-viewer__index--Color: var(--pf-t--global--text--color--subtle); 25 | --pf-v6-c-log-viewer__index--BackgroundColor: transparent; 26 | --pf-v6-c-log-viewer--line-number-chars: 4.4; 27 | --pf-v6-c-log-viewer--m-line-number-chars__index--PaddingInlineEnd: var(--pf-t--global--spacer--xs); 28 | --pf-v6-c-log-viewer--m-line-number-chars__index--Width: calc(1ch * var(--pf-v6-c-log-viewer--line-number-chars) + var(--pf-v6-c-log-viewer__index--PaddingInlineEnd) + var(--pf-v6-c-log-viewer__index--PaddingInlineStart)); 29 | --pf-v6-c-log-viewer__text--PaddingInlineEnd: var(--pf-t--global--spacer--md); 30 | --pf-v6-c-log-viewer__text--PaddingInlineStart: var(--pf-t--global--spacer--md); 31 | --pf-v6-c-log-viewer__text--Color: var(--pf-t--global--text--color--regular); 32 | --pf-v6-c-log-viewer__text--WordBreak: break-all; 33 | --pf-v6-c-log-viewer__text--WhiteSpace: break-spaces; 34 | --pf-v6-c-log-viewer__text--LineBreak: anywhere; 35 | --pf-v6-c-log-viewer--m-nowrap__text--WhiteSpace: nowrap; 36 | --pf-v6-c-log-viewer__string--m-match--Color: var(--pf-t--global--text--color--on-highlight); 37 | --pf-v6-c-log-viewer__string--m-match--BackgroundColor: var(--pf-t--global--background--color--highlight--default); 38 | --pf-v6-c-log-viewer__string--m-current--Color: var(--pf-t--global--text--color--on-highlight); 39 | --pf-v6-c-log-viewer__string--m-current--BackgroundColor: var(--pf-t--global--background--color--highlight--clicked); 40 | --pf-v6-c-log-viewer__timestamp--FontWeight: var(--pf-t--global--font--weight--body--bold); 41 | --pf-v6-c-log-viewer--c-toolbar--PaddingBlockStart: 0; 42 | --pf-v6-c-log-viewer--c-toolbar--PaddingBlockEnd: 0; 43 | --pf-v6-c-log-viewer--c-toolbar__content--PaddingInlineEnd: 0; 44 | --pf-v6-c-log-viewer--c-toolbar__content--PaddingInlineStart: 0; 45 | --pf-v6-c-log-viewer--c-toolbar__group--m-toggle-group--spacer: 0; 46 | --pf-v6-c-log-viewer--c-toolbar__group--m-toggle-group--m-show--spacer: var(--pf-t--global--spacer--sm); 47 | --pf-v6-c-log-viewer--m-dark__main--BorderWidth: 0; 48 | --pf-v6-c-log-viewer--m-dark__main--BackgroundColor: var(--pf-t--global--background--color--inverse--default); 49 | --pf-v6-c-log-viewer--m-dark__text--Color: var(--pf-t--global--text--color--inverse); 50 | --pf-v6-c-log-viewer--m-dark__index--Color: var(--pf-t--global--text--color--inverse); 51 | display: flex; 52 | flex-direction: column; 53 | height: var(--pf-v6-c-log-viewer--Height); 54 | max-height: var(--pf-v6-c-log-viewer--MaxHeight); 55 | } 56 | 57 | html:not(.pf-v6-theme-dark) .pf-v6-c-log-viewer.pf-m-dark { 58 | --pf-v6-c-log-viewer__main--BorderWidth: var(--pf-v6-c-log-viewer--m-dark__main--BorderWidth); 59 | --pf-v6-c-log-viewer__main--BackgroundColor: var(--pf-v6-c-log-viewer--m-dark__main--BackgroundColor); 60 | --pf-v6-c-log-viewer__text--Color: var(--pf-v6-c-log-viewer--m-dark__text--Color); 61 | --pf-v6-c-log-viewer__index--Color: var( --pf-v6-c-log-viewer--m-dark__index--Color); 62 | } 63 | 64 | .pf-v6-c-log-viewer.pf-m-wrap-text { 65 | word-break: break-all; 66 | } 67 | .pf-v6-c-log-viewer.pf-m-nowrap { 68 | --pf-v6-c-log-viewer__text--WhiteSpace: var(--pf-v6-c-log-viewer--m-nowrap__text--WhiteSpace); 69 | } 70 | .pf-v6-c-log-viewer.pf-m-line-numbers { 71 | --pf-v6-c-log-viewer__index--Display: var(--pf-v6-c-log-viewer--m-line-numbers__index--Display); 72 | } 73 | .pf-v6-c-log-viewer.pf-m-line-numbers .pf-v6-c-log-viewer__list { 74 | position: absolute; 75 | inset-inline-start: var(--pf-v6-c-log-viewer--m-line-numbers__list--InsetInlineStart); 76 | inset-inline-end: 0; 77 | } 78 | .pf-v6-c-log-viewer.pf-m-line-numbers .pf-v6-c-log-viewer__list::before { 79 | position: absolute; 80 | inset-block-start: var(--pf-v6-c-log-viewer--m-line-numbers__list--before--InsetBlockStart); 81 | inset-block-end: var(--pf-v6-c-log-viewer--m-line-numbers__list--before--InsetBlockEnd); 82 | inset-inline-start: 0; 83 | width: var(--pf-v6-c-log-viewer--m-line-numbers__list--before--Width); 84 | content: ""; 85 | background: var(--pf-v6-c-log-viewer--m-line-numbers__list--before--BackgroundColor); 86 | } 87 | .pf-v6-c-log-viewer.pf-m-line-number-chars { 88 | --pf-v6-c-log-viewer__index--PaddingInlineEnd: var(--pf-v6-c-log-viewer--m-line-number-chars__index--PaddingInlineEnd); 89 | --pf-v6-c-log-viewer__index--Width: var(--pf-v6-c-log-viewer--m-line-number-chars__index--Width); 90 | } 91 | .pf-v6-c-log-viewer .pf-v6-c-toolbar { 92 | --pf-v6-c-toolbar--PaddingBlockStart: var(--pf-v6-c-log-viewer--c-toolbar--PaddingBlockStart); 93 | --pf-v6-c-toolbar--PaddingBlockEnd: var(--pf-v6-c-log-viewer--c-toolbar--PaddingBlockEnd); 94 | --pf-v6-c-toolbar__content--PaddingInlineEnd: var(--pf-v6-c-log-viewer--c-toolbar__content--PaddingInlineEnd); 95 | --pf-v6-c-toolbar__content--PaddingInlineStart: var(--pf-v6-c-log-viewer--c-toolbar__content--PaddingInlineStart); 96 | --pf-v6-c-toolbar__group--m-toggle-group--spacer: 0; 97 | --pf-v6-c-toolbar__group--m-toggle-group--m-show--spacer: var(--pf-v6-c-log-viewer--c-toolbar__group--m-toggle-group--m-show--spacer); 98 | } 99 | .pf-v6-c-log-viewer .pf-v6-c-toolbar__content-section { 100 | flex-wrap: nowrap; 101 | } 102 | 103 | .pf-v6-c-log-viewer__header { 104 | margin-block-end: var(--pf-v6-c-log-viewer__header--MarginBlockEnd); 105 | } 106 | 107 | .pf-v6-c-log-viewer__main { 108 | display: flex; 109 | flex-direction: column; 110 | min-height: 0; 111 | background-color: var(--pf-v6-c-log-viewer__main--BackgroundColor); 112 | border: var(--pf-v6-c-log-viewer__main--BorderWidth) solid var(--pf-v6-c-log-viewer__main--BorderColor); 113 | } 114 | 115 | .pf-v6-c-log-viewer__scroll-container { 116 | position: relative; 117 | height: var(--pf-v6-c-log-viewer__scroll-container--Height); 118 | padding-block-start: var(--pf-v6-c-log-viewer__scroll-container--PaddingBlockStart); 119 | padding-block-end: var(--pf-v6-c-log-viewer__scroll-container--PaddingBlockEnd); 120 | overflow: auto; 121 | will-change: transform; 122 | direction: ltr; 123 | } 124 | 125 | .pf-v6-c-log-viewer__list { 126 | position: relative; 127 | height: var(--pf-v6-c-log-viewer__list--Height); 128 | font-family: var(--pf-v6-c-log-viewer__list--FontFamily); 129 | font-size: var(--pf-v6-c-log-viewer__list--FontSize); 130 | } 131 | 132 | .pf-v6-c-log-viewer__list-item { 133 | inset-inline-start: 0; 134 | width: 100%; 135 | } 136 | 137 | .pf-v6-c-log-viewer__string.pf-m-match { 138 | color: var(--pf-v6-c-log-viewer__string--m-match--Color, inherit); 139 | background-color: var(--pf-v6-c-log-viewer__string--m-match--BackgroundColor); 140 | } 141 | .pf-v6-c-log-viewer__string.pf-m-current { 142 | color: var(--pf-v6-c-log-viewer__string--m-current--Color, inherit); 143 | background-color: var(--pf-v6-c-log-viewer__string--m-current--BackgroundColor); 144 | } 145 | 146 | .pf-v6-c-log-viewer__index { 147 | position: fixed; 148 | inset-inline-start: 0; 149 | display: var(--pf-v6-c-log-viewer__index--Display); 150 | width: var(--pf-v6-c-log-viewer__index--Width); 151 | padding-inline-start: var(--pf-v6-c-log-viewer__index--PaddingInlineStart); 152 | padding-inline-end: var(--pf-v6-c-log-viewer__index--PaddingInlineEnd); 153 | font-family: var(--pf-v6-c-log-viewer__index--FontFamily, inherit); 154 | font-size: var(--pf-v6-c-log-viewer__index--FontSize, inherit); 155 | color: var(--pf-v6-c-log-viewer__index--Color); 156 | user-select: none; 157 | background-color: var(--pf-v6-c-log-viewer__index--BackgroundColor); 158 | } 159 | 160 | .pf-v6-c-log-viewer__text { 161 | display: block; 162 | padding-inline-start: var(--pf-v6-c-log-viewer__text--PaddingInlineStart); 163 | padding-inline-end: var(--pf-v6-c-log-viewer__text--PaddingInlineEnd); 164 | font-family: var(--pf-v6-c-log-viewer__text--FontFamily, inherit); 165 | font-size: var(--pf-v6-c-log-viewer__text--FontSize, inherit); 166 | color: var(--pf-v6-c-log-viewer__text--Color); 167 | word-break: var(--pf-v6-c-log-viewer__text--WordBreak); 168 | white-space: var(--pf-v6-c-log-viewer__text--WhiteSpace); 169 | line-break: var(--pf-v6-c-log-viewer__text--LineBreak); 170 | } 171 | 172 | .pf-v6-c-log-viewer__timestamp { 173 | font-weight: var(--pf-v6-c-log-viewer__timestamp--FontWeight); 174 | } -------------------------------------------------------------------------------- /packages/module/src/LogViewer/css/log-viewer.ts: -------------------------------------------------------------------------------- 1 | import './log-viewer.css'; 2 | export default { 3 | "logViewer": "pf-v6-c-log-viewer", 4 | "logViewerHeader": "pf-v6-c-log-viewer__header", 5 | "logViewerIndex": "pf-v6-c-log-viewer__index", 6 | "logViewerList": "pf-v6-c-log-viewer__list", 7 | "logViewerListItem": "pf-v6-c-log-viewer__list-item", 8 | "logViewerMain": "pf-v6-c-log-viewer__main", 9 | "logViewerScrollContainer": "pf-v6-c-log-viewer__scroll-container", 10 | "logViewerString": "pf-v6-c-log-viewer__string", 11 | "logViewerText": "pf-v6-c-log-viewer__text", 12 | "logViewerTimestamp": "pf-v6-c-log-viewer__timestamp", 13 | "modifiers": { 14 | "dark": "pf-m-dark", 15 | "wrapText": "pf-m-wrap-text", 16 | "nowrap": "pf-m-nowrap", 17 | "lineNumbers": "pf-m-line-numbers", 18 | "lineNumberChars": "pf-m-line-number-chars", 19 | "match": "pf-m-match", 20 | "current": "pf-m-current" 21 | }, 22 | "themeDark": "pf-v6-theme-dark", 23 | "toolbar": "pf-v6-c-toolbar", 24 | "toolbarContentSection": "pf-v6-c-toolbar__content-section" 25 | }; -------------------------------------------------------------------------------- /packages/module/src/LogViewer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LogViewer'; 2 | export * from './LogViewerContext'; 3 | export * from './LogViewerSearch'; 4 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOGGER_LINE_NUMBER_INDEX_DELTA: number = 1; 2 | export const NUMBER_INDEX_DELTA: number = 1; 3 | export const DEFAULT_AMOUNT: number = 0; 4 | export const DEFAULT_INDEX: number = 0; 5 | export const DEFAULT_MATCH: number = 0; 6 | export const DEFAULT_FOCUS: number = -1; 7 | export const MIN_SEARCH_INPUT_LENGTH: number = 3; 8 | export const DEFAULT_SEARCH_INDEX = 0; 9 | -------------------------------------------------------------------------------- /packages/module/src/LogViewer/utils/utils.tsx: -------------------------------------------------------------------------------- 1 | export interface searchedKeyWordType { 2 | rowIndex: number; 3 | matchIndex: number; 4 | } 5 | 6 | export const isArrayOfString = (array: string[]) => { 7 | for (const str in array) { 8 | if (typeof str !== 'string') { 9 | return false; 10 | } 11 | } 12 | return true; 13 | }; 14 | 15 | /* 16 | Function responsible for searching throughout logger component, need to setup for proper use anywhere. 17 | It should take an array, and return an array of indexes where the searchedInput is found throughout the data array. 18 | Should always be searching an array of strings. Look into lazy log for ideas. 19 | */ 20 | export const searchForKeyword = (searchedInput: string, parsedData: string[], itemCount: number) => { 21 | const searchResults: searchedKeyWordType[] = []; 22 | 23 | const regex = new RegExp(searchedInput, 'ig'); 24 | parsedData.map((row, index) => { 25 | const rawRow = stripAnsi(row); 26 | if (regex.test(rawRow) && index < itemCount) { 27 | const numMatches = rawRow.match(regex).length; 28 | for (let i = 1; i <= numMatches; i++) { 29 | searchResults.push({ rowIndex: index, matchIndex: i }); 30 | } 31 | } 32 | }); 33 | 34 | if (searchResults.length > 0) { 35 | return [...searchResults]; 36 | } else if (searchResults.length <= 0) { 37 | return [{ rowIndex: -1, matchIndex: 0 }]; 38 | } 39 | }; 40 | 41 | export const parseConsoleOutput = (data: string[] | string) => { 42 | const stringToSplitWith = '\n'; 43 | const parsedData = Array.isArray(data) ? data.join(stringToSplitWith) : data; 44 | const stringSplitting = parsedData.toString(); 45 | const cleanString = stringSplitting.split(stringToSplitWith); 46 | 47 | return cleanString; 48 | }; 49 | 50 | export const escapeString = (inputString: string): string => inputString.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // eslint-disable-line 51 | 52 | /* eslint-disable-next-line no-control-regex */ 53 | const ansiRegexString = `[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><]`; 54 | 55 | export const ansiRegex = new RegExp(ansiRegexString, 'g'); 56 | 57 | export const isAnsi = (inputString: string) => inputString.match(ansiRegex); 58 | 59 | export const stripAnsi = (inputString: string): string => inputString.replace(ansiRegex, ''); 60 | 61 | export const splitAnsi = (inputString: string): string[] => inputString.split(new RegExp(`(${ansiRegexString})`, 'g')); 62 | 63 | export const escapeTextForHtml = (inputString: string): string => 64 | inputString.replace(/[&<>"']/gm, str => { 65 | if (str === '&') { 66 | return '&'; 67 | } 68 | if (str === '<') { 69 | return '<'; 70 | } 71 | if (str === '>') { 72 | return '>'; 73 | } 74 | if (str === '"') { 75 | return '"'; 76 | } 77 | if (str === "'") { 78 | return '''; 79 | } 80 | }); 81 | -------------------------------------------------------------------------------- /packages/module/src/ansi_up/ansi_up.ts: -------------------------------------------------------------------------------- 1 | /* ansi_up.js 2 | * author : Dru Nelson 3 | * license : MIT 4 | * http://github.com/drudru/ansi_up 5 | */ 6 | 7 | // 8 | // INTERFACES 9 | // 10 | /* eslint-disable */ 11 | interface AU_Color { 12 | rgb: number[]; 13 | class_name: string; 14 | } 15 | 16 | // Represents the output of process_ansi(): a snapshot of the AnsiUp state machine 17 | // at a given point in time, which wraps a fragment of text. This would allow deferred 18 | // processing of text fragments and colors, if ever needed. 19 | interface TextWithAttr { 20 | fg: AU_Color; 21 | bg: AU_Color; 22 | bold: boolean; 23 | faint: boolean; 24 | italic: boolean; 25 | underline: boolean; 26 | text: string; 27 | } 28 | 29 | // Used internally when breaking up the raw text into packets 30 | 31 | enum PacketKind { 32 | EOS, 33 | Text, 34 | Incomplete, // An Incomplete ESC sequence 35 | ESC, // A single ESC char - random 36 | Unknown, // A valid CSI but not an SGR code 37 | SGR, // Select Graphic Rendition 38 | OSCURL // Operating System Command 39 | } 40 | 41 | interface TextPacket { 42 | kind: PacketKind; 43 | text: string; 44 | url: string; 45 | } 46 | 47 | // 48 | // MAIN CLASS 49 | // 50 | 51 | export default class AnsiUp { 52 | VERSION = '6.0.2'; 53 | 54 | // 55 | // *** SEE README ON GITHUB FOR PUBLIC API *** 56 | // 57 | 58 | // 256 Colors Palette 59 | // CSS RGB strings - ex. "255, 255, 255" 60 | private ansi_colors: AU_Color[][]; 61 | private palette_256: AU_Color[]; 62 | 63 | private fg: AU_Color; 64 | private bg: AU_Color; 65 | private bold: boolean; 66 | private faint: boolean; 67 | private italic: boolean; 68 | private underline: boolean; 69 | private _use_classes: boolean; 70 | 71 | private _csi_regex: RegExp; 72 | 73 | private _osc_st: RegExp; 74 | private _osc_regex: RegExp; 75 | 76 | private _url_allowlist: Record = {}; 77 | private _escape_html: boolean; 78 | 79 | private _buffer: string; 80 | 81 | private _boldStyle: string; 82 | private _faintStyle: string; 83 | private _italicStyle: string; 84 | private _underlineStyle: string; 85 | 86 | constructor() { 87 | // All construction occurs here 88 | this.setup_palettes(); 89 | this.resetStyles(); 90 | 91 | this._use_classes = false; 92 | } 93 | 94 | set use_classes(arg: boolean) { 95 | this._use_classes = arg; 96 | } 97 | 98 | get use_classes(): boolean { 99 | return this._use_classes; 100 | } 101 | 102 | set url_allowlist(arg: Record) { 103 | this._url_allowlist = arg; 104 | } 105 | 106 | get url_allowlist(): Record { 107 | return this._url_allowlist; 108 | } 109 | 110 | set escape_html(arg: boolean) 111 | { 112 | this._escape_html = arg; 113 | } 114 | 115 | get escape_html(): boolean 116 | { 117 | return this._escape_html; 118 | } 119 | 120 | set boldStyle(arg: string) { this._boldStyle = arg; } 121 | get boldStyle(): string { return this._boldStyle; } 122 | set faintStyle(arg: string) { this._faintStyle = arg; } 123 | get faintStyle(): string { return this._faintStyle; } 124 | set italicStyle(arg: string) { this._italicStyle = arg; } 125 | get italicStyle(): string { return this._italicStyle; } 126 | set underlineStyle(arg: string) { this._underlineStyle = arg; } 127 | get underlineStyle(): string { return this._underlineStyle; } 128 | 129 | private setup_palettes(): void { 130 | this.ansi_colors = [ 131 | // Normal colors 132 | [ 133 | { rgb: [0, 0, 0], class_name: 'ansi-black' }, 134 | { rgb: [187, 0, 0], class_name: 'ansi-red' }, 135 | { rgb: [0, 187, 0], class_name: 'ansi-green' }, 136 | { rgb: [187, 187, 0], class_name: 'ansi-yellow' }, 137 | { rgb: [0, 0, 187], class_name: 'ansi-blue' }, 138 | { rgb: [187, 0, 187], class_name: 'ansi-magenta' }, 139 | { rgb: [0, 187, 187], class_name: 'ansi-cyan' }, 140 | { rgb: [255, 255, 255], class_name: 'ansi-white' } 141 | ], 142 | 143 | // Bright colors 144 | [ 145 | { rgb: [85, 85, 85], class_name: 'ansi-bright-black' }, 146 | { rgb: [255, 85, 85], class_name: 'ansi-bright-red' }, 147 | { rgb: [0, 255, 0], class_name: 'ansi-bright-green' }, 148 | { rgb: [255, 255, 85], class_name: 'ansi-bright-yellow' }, 149 | { rgb: [85, 85, 255], class_name: 'ansi-bright-blue' }, 150 | { rgb: [255, 85, 255], class_name: 'ansi-bright-magenta' }, 151 | { rgb: [85, 255, 255], class_name: 'ansi-bright-cyan' }, 152 | { rgb: [255, 255, 255], class_name: 'ansi-bright-white' } 153 | ] 154 | ]; 155 | 156 | this.palette_256 = []; 157 | 158 | // Index 0..15 : Ansi-Colors 159 | this.ansi_colors.forEach(palette => { 160 | palette.forEach(rec => { 161 | this.palette_256.push(rec); 162 | }); 163 | }); 164 | 165 | // Index 16..231 : RGB 6x6x6 166 | // https://gist.github.com/jasonm23/2868981#file-xterm-256color-yaml 167 | const levels = [0, 95, 135, 175, 215, 255]; 168 | for (let r = 0; r < 6; ++r) { 169 | for (let g = 0; g < 6; ++g) { 170 | for (let b = 0; b < 6; ++b) { 171 | const col = { rgb: [levels[r], levels[g], levels[b]], class_name: 'truecolor' }; 172 | this.palette_256.push(col); 173 | } 174 | } 175 | } 176 | 177 | // Index 232..255 : Grayscale 178 | let grey_level = 8; 179 | for (let i = 0; i < 24; ++i, grey_level += 10) { 180 | const gry = { rgb: [grey_level, grey_level, grey_level], class_name: 'truecolor' }; 181 | this.palette_256.push(gry); 182 | } 183 | } 184 | 185 | private escape_txt_for_html(txt: string): string { 186 | if (!this._escape_html) { 187 | return txt; 188 | } 189 | 190 | return txt.replace(/[&<>"']/gm, str => { 191 | if (str === '&') { 192 | return '&'; 193 | } 194 | if (str === '<') { 195 | return '<'; 196 | } 197 | if (str === '>') { 198 | return '>'; 199 | } 200 | if (str === '"') { 201 | return '"'; 202 | } 203 | if (str === "'") { 204 | return '''; 205 | } 206 | }); 207 | } 208 | 209 | private append_buffer(txt: string) { 210 | const str = this._buffer + txt; 211 | this._buffer = str; 212 | } 213 | 214 | private get_next_packet(): TextPacket { 215 | const pkt = { 216 | kind: PacketKind.EOS, 217 | text: '', 218 | url: '' 219 | }; 220 | 221 | const len = this._buffer.length; 222 | if (len == 0) { 223 | return pkt; 224 | } 225 | 226 | const pos = this._buffer.indexOf('\x1B'); 227 | 228 | // The most common case, no ESC codes 229 | if (pos == -1) { 230 | pkt.kind = PacketKind.Text; 231 | pkt.text = this._buffer; 232 | this._buffer = ''; 233 | return pkt; 234 | } 235 | 236 | if (pos > 0) { 237 | pkt.kind = PacketKind.Text; 238 | pkt.text = this._buffer.slice(0, pos); 239 | this._buffer = this._buffer.slice(pos); 240 | return pkt; 241 | } 242 | 243 | // NOW WE HANDLE ESCAPES 244 | if (pos == 0) { 245 | // All of the sequences typically need at least 3 characters 246 | // So, wait until we have at least that many 247 | if (len < 3) { 248 | // Lone ESC in Buffer, We don't know yet 249 | pkt.kind = PacketKind.Incomplete; 250 | return pkt; 251 | } 252 | 253 | const next_char = this._buffer.charAt(1); 254 | 255 | // We treat this as a single ESC 256 | // No transformation 257 | if (next_char != '[' && next_char != ']' && (next_char != '(')) { 258 | pkt.kind = PacketKind.ESC; 259 | pkt.text = this._buffer.slice(0, 1); 260 | this._buffer = this._buffer.slice(1); 261 | return pkt; 262 | } 263 | 264 | // OK is this an SGR or OSC that we handle 265 | 266 | // SGR CHECK 267 | if (next_char == '[') { 268 | // We do this regex initialization here so 269 | // we can keep the regex close to its use (Readability) 270 | 271 | // All ansi codes are typically in the following format. 272 | // We parse it and focus specifically on the 273 | // graphics commands (SGR) 274 | // 275 | // CONTROL-SEQUENCE-INTRODUCER CSI (ESC, '[') 276 | // PRIVATE-MODE-CHAR (!, <, >, ?) 277 | // Numeric parameters separated by semicolons ('0' - '9', ';') 278 | // Intermediate-modifiers (0x20 - 0x2f) 279 | // COMMAND-CHAR (0x40 - 0x7e) 280 | // 281 | 282 | if (!this._csi_regex) { 283 | this._csi_regex = rgx` 284 | ^ # beginning of line 285 | # 286 | # First attempt 287 | (?: # legal sequence 288 | \x1b\[ # CSI 289 | ([\x3c-\x3f]?) # private-mode char 290 | ([\d;]*) # any digits or semicolons 291 | ([\x20-\x2f]? # an intermediate modifier 292 | [\x40-\x7e]) # the command 293 | ) 294 | | # alternate (second attempt) 295 | (?: # illegal sequence 296 | \x1b\[ # CSI 297 | [\x20-\x7e]* # anything legal 298 | ([\x00-\x1f:]) # anything illegal 299 | ) 300 | `; 301 | } 302 | 303 | const match = this._buffer.match(this._csi_regex); 304 | 305 | // This match is guaranteed to terminate (even on 306 | // invalid input). The key is to match on legal and 307 | // illegal sequences. 308 | // The first alternate matches everything legal and 309 | // the second matches everything illegal. 310 | // 311 | // If it doesn't match, then we have not received 312 | // either the full sequence or an illegal sequence. 313 | // If it does match, the presence of field 4 tells 314 | // us whether it was legal or illegal. 315 | 316 | if (match === null) { 317 | pkt.kind = PacketKind.Incomplete; 318 | return pkt; 319 | } 320 | 321 | // match is an array 322 | // 0 - total match 323 | // 1 - private mode chars group 324 | // 2 - digits and semicolons group 325 | // 3 - command 326 | // 4 - illegal char 327 | 328 | if (match[4]) { 329 | // Illegal sequence, just remove the ESC 330 | pkt.kind = PacketKind.ESC; 331 | pkt.text = this._buffer.slice(0, 1); 332 | this._buffer = this._buffer.slice(1); 333 | return pkt; 334 | } 335 | 336 | // If not a valid SGR, we don't handle 337 | if (match[1] != '' || match[3] != 'm') { 338 | pkt.kind = PacketKind.Unknown; 339 | } else { 340 | pkt.kind = PacketKind.SGR; 341 | } 342 | 343 | pkt.text = match[2]; // Just the parameters 344 | 345 | var rpos = match[0].length; 346 | this._buffer = this._buffer.slice(rpos); 347 | return pkt; 348 | } else if (next_char == ']') { 349 | // OSC CHECK 350 | if (len < 4) { 351 | pkt.kind = PacketKind.Incomplete; 352 | return pkt; 353 | } 354 | 355 | if (this._buffer.charAt(2) != '8' || this._buffer.charAt(3) != ';') { 356 | // This is not a match, so we'll just treat it as ESC 357 | pkt.kind = PacketKind.ESC; 358 | pkt.text = this._buffer.slice(0, 1); 359 | this._buffer = this._buffer.slice(1); 360 | return pkt; 361 | } 362 | 363 | // We do this regex initialization here so 364 | // we can keep the regex close to its use (Readability) 365 | 366 | // Matching a Hyperlink OSC with a regex is difficult 367 | // because Javascript's regex engine doesn't support 368 | // 'partial match' support. 369 | // 370 | // Therefore, we require the system to match the 371 | // string-terminator(ST) before attempting a match. 372 | // Once we find it, we attempt the Hyperlink-Begin 373 | // match. 374 | // If that goes ok, we scan forward for the next 375 | // ST. 376 | // Finally, we try to match it all and return 377 | // the sequence. 378 | // Also, it is important to note that we consider 379 | // certain control characters as an invalidation of 380 | // the entire sequence. 381 | 382 | // We do regex initializations here so 383 | // we can keep the regex close to its use (Readability) 384 | 385 | // STRING-TERMINATOR 386 | // This is likely to terminate in most scenarios 387 | // because it will terminate on a newline 388 | 389 | if (!this._osc_st) { 390 | this._osc_st = rgxG` 391 | (?: # legal sequence 392 | (\x1b\\) # ESC \ 393 | | # alternate 394 | (\x07) # BEL (what xterm did) 395 | ) 396 | | # alternate (second attempt) 397 | ( # illegal sequence 398 | [\x00-\x06] # anything illegal 399 | | # alternate 400 | [\x08-\x1a] # anything illegal 401 | | # alternate 402 | [\x1c-\x1f] # anything illegal 403 | ) 404 | `; 405 | } 406 | 407 | // VERY IMPORTANT 408 | // We do a stateful regex match with exec. 409 | // If the regex is global, and it used with 'exec', 410 | // then it will search starting at the 'lastIndex' 411 | // If it matches, the regex can be used again to 412 | // find the next match. 413 | this._osc_st.lastIndex = 0; 414 | 415 | { 416 | const match = this._osc_st.exec(this._buffer); 417 | 418 | if (match === null) { 419 | pkt.kind = PacketKind.Incomplete; 420 | return pkt; 421 | } 422 | 423 | // If an illegal character was found, bail on the match 424 | if (match[3]) { 425 | // Illegal sequence, just remove the ESC 426 | pkt.kind = PacketKind.ESC; 427 | pkt.text = this._buffer.slice(0, 1); 428 | this._buffer = this._buffer.slice(1); 429 | return pkt; 430 | } 431 | } 432 | 433 | // OK - we might have the prefix and URI 434 | // Lets start our search for the next ST 435 | // past this index 436 | 437 | { 438 | const match = this._osc_st.exec(this._buffer); 439 | 440 | if (match === null) { 441 | pkt.kind = PacketKind.Incomplete; 442 | return pkt; 443 | } 444 | 445 | // If an illegal character was found, bail on the match 446 | if (match[3]) { 447 | // Illegal sequence, just remove the ESC 448 | pkt.kind = PacketKind.ESC; 449 | pkt.text = this._buffer.slice(0, 1); 450 | this._buffer = this._buffer.slice(1); 451 | return pkt; 452 | } 453 | } 454 | 455 | // OK, at this point we should have a FULL match! 456 | // 457 | // Lets try to match that now 458 | 459 | if (!this._osc_regex) { 460 | this._osc_regex = rgx` 461 | ^ # beginning of line 462 | # 463 | \x1b\]8; # OSC Hyperlink 464 | [\x20-\x3a\x3c-\x7e]* # params (excluding ;) 465 | ; # end of params 466 | ([\x21-\x7e]{0,512}) # URL capture 467 | (?: # ST 468 | (?:\x1b\\) # ESC \ 469 | | # alternate 470 | (?:\x07) # BEL (what xterm did) 471 | ) 472 | ([\x20-\x7e]+) # TEXT capture 473 | \x1b\]8;; # OSC Hyperlink End 474 | (?: # ST 475 | (?:\x1b\\) # ESC \ 476 | | # alternate 477 | (?:\x07) # BEL (what xterm did) 478 | ) 479 | `; 480 | } 481 | 482 | const match = this._buffer.match(this._osc_regex); 483 | 484 | if (match === null) { 485 | // Illegal sequence, just remove the ESC 486 | pkt.kind = PacketKind.ESC; 487 | pkt.text = this._buffer.slice(0, 1); 488 | this._buffer = this._buffer.slice(1); 489 | return pkt; 490 | } 491 | 492 | // match is an array 493 | // 0 - total match 494 | // 1 - URL 495 | // 2 - Text 496 | 497 | // If a valid SGR 498 | pkt.kind = PacketKind.OSCURL; 499 | pkt.url = match[1]; 500 | pkt.text = match[2]; 501 | 502 | var rpos = match[0].length; 503 | this._buffer = this._buffer.slice(rpos); 504 | return pkt; 505 | } else if (next_char == '(') { 506 | // Other ESC CHECK 507 | // This specifies the character set, which 508 | // should just be ignored 509 | 510 | // We have at least 3, so drop the sequence 511 | pkt.kind = PacketKind.Unknown; 512 | this._buffer = this._buffer.slice(3); 513 | return pkt; 514 | } 515 | } 516 | } 517 | 518 | ansi_to_html(txt: string): string { 519 | this.append_buffer(txt); 520 | 521 | const blocks: string[] = []; 522 | 523 | while (true) { 524 | const packet = this.get_next_packet(); 525 | 526 | if (packet.kind == PacketKind.EOS || packet.kind == PacketKind.Incomplete) { 527 | break; 528 | } 529 | 530 | // Drop single ESC or Unknown CSI 531 | if (packet.kind == PacketKind.ESC || packet.kind == PacketKind.Unknown) { 532 | continue; 533 | } 534 | 535 | if (packet.kind == PacketKind.Text) { 536 | blocks.push(this.transform_to_html(this.with_state(packet))); 537 | } else if (packet.kind == PacketKind.SGR) { 538 | this.process_ansi(packet); 539 | } else if (packet.kind == PacketKind.OSCURL) { 540 | blocks.push(this.process_hyperlink(packet)); 541 | } 542 | } 543 | 544 | return blocks.join(''); 545 | } 546 | 547 | resetStyles() { 548 | this.bold = false; 549 | this.faint = false; 550 | this.italic = false; 551 | this.underline = false; 552 | this.fg = this.bg = null; 553 | 554 | this._buffer = ''; 555 | 556 | this._url_allowlist = { http: 1, https: 1 }; 557 | 558 | this.boldStyle = 'font-weight:bold'; 559 | this.faintStyle = 'opacity:0.7'; 560 | this.italicStyle = 'font-style:italic'; 561 | this.underlineStyle = 'text-decoration:underline'; 562 | } 563 | 564 | private with_state(pkt: TextPacket): TextWithAttr { 565 | return { 566 | bold: this.bold, 567 | faint: this.faint, 568 | italic: this.italic, 569 | underline: this.underline, 570 | fg: this.fg, 571 | bg: this.bg, 572 | text: pkt.text 573 | }; 574 | } 575 | 576 | private process_ansi(pkt: TextPacket) { 577 | // Ok - we have a valid "SGR" (Select Graphic Rendition) 578 | 579 | const sgr_cmds = pkt.text.split(';'); 580 | 581 | // Each of these params affects the SGR state 582 | 583 | // Why do we shift through the array instead of a forEach?? 584 | // ... because some commands consume the params that follow ! 585 | while (sgr_cmds.length > 0) { 586 | const sgr_cmd_str = sgr_cmds.shift(); 587 | const num = parseInt(sgr_cmd_str, 10); 588 | 589 | // TODO 590 | // AT SOME POINT, JUST CONVERT TO A LOOKUP TABLE 591 | if (isNaN(num) || num === 0) { 592 | this.fg = null; 593 | this.bg = null; 594 | this.bold = false; 595 | this.faint = false; 596 | this.italic = false; 597 | this.underline = false; 598 | } else if (num === 1) { 599 | this.bold = true; 600 | } else if (num === 2) { 601 | this.faint = true; 602 | } else if (num === 3) { 603 | this.italic = true; 604 | } else if (num === 4) { 605 | this.underline = true; 606 | } else if (num === 21) { 607 | this.bold = false; 608 | } else if (num === 22) { 609 | this.faint = false; 610 | this.bold = false; 611 | } else if (num === 23) { 612 | this.italic = false; 613 | } else if (num === 24) { 614 | this.underline = false; 615 | } else if (num === 39) { 616 | this.fg = null; 617 | } else if (num === 49) { 618 | this.bg = null; 619 | } else if (num >= 30 && num < 38) { 620 | this.fg = this.ansi_colors[0][num - 30]; 621 | } else if (num >= 40 && num < 48) { 622 | this.bg = this.ansi_colors[0][num - 40]; 623 | } else if (num >= 90 && num < 98) { 624 | this.fg = this.ansi_colors[1][num - 90]; 625 | } else if (num >= 100 && num < 108) { 626 | this.bg = this.ansi_colors[1][num - 100]; 627 | } else if (num === 38 || num === 48) { 628 | // extended set foreground/background color 629 | 630 | // validate that param exists 631 | if (sgr_cmds.length > 0) { 632 | // extend color (38=fg, 48=bg) 633 | const is_foreground = num === 38; 634 | 635 | const mode_cmd = sgr_cmds.shift(); 636 | 637 | // MODE '5' - 256 color palette 638 | if (mode_cmd === '5' && sgr_cmds.length > 0) { 639 | const palette_index = parseInt(sgr_cmds.shift(), 10); 640 | if (palette_index >= 0 && palette_index <= 255) { 641 | if (is_foreground) { 642 | this.fg = this.palette_256[palette_index]; 643 | } else { 644 | this.bg = this.palette_256[palette_index]; 645 | } 646 | } 647 | } 648 | 649 | // MODE '2' - True Color 650 | if (mode_cmd === '2' && sgr_cmds.length > 2) { 651 | const r = parseInt(sgr_cmds.shift(), 10); 652 | const g = parseInt(sgr_cmds.shift(), 10); 653 | const b = parseInt(sgr_cmds.shift(), 10); 654 | 655 | if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { 656 | const c = { rgb: [r, g, b], class_name: 'truecolor' }; 657 | if (is_foreground) { 658 | this.fg = c; 659 | } else { 660 | this.bg = c; 661 | } 662 | } 663 | } 664 | } 665 | } 666 | } 667 | } 668 | 669 | private transform_to_html(fragment: TextWithAttr): string { 670 | let txt = fragment.text; 671 | 672 | if (txt.length === 0) { 673 | return txt; 674 | } 675 | 676 | txt = this.escape_txt_for_html(txt); 677 | 678 | // If colors not set, default style is used 679 | if (!fragment.bold && !fragment.italic && !fragment.underline && fragment.fg === null && fragment.bg === null) { 680 | return txt; 681 | } 682 | 683 | const styles: string[] = []; 684 | const classes: string[] = []; 685 | 686 | const fg = fragment.fg; 687 | const bg = fragment.bg; 688 | 689 | if (!this._use_classes) { 690 | // USE INLINE STYLES 691 | 692 | // Note on bold: https://stackoverflow.com/questions/6737005/what-are-some-advantages-to-using-span-style-font-weightbold-rather-than-b?rq=1 693 | if (fragment.bold) { 694 | styles.push(this._boldStyle); 695 | } 696 | 697 | if (fragment.faint) { 698 | styles.push(this._faintStyle); 699 | } 700 | 701 | if (fragment.italic) { 702 | styles.push(this._italicStyle); 703 | } 704 | 705 | if (fragment.underline) { 706 | styles.push(this._underlineStyle); 707 | } 708 | 709 | if (fg) { 710 | styles.push(`color:rgb(${fg.rgb.join(',')})`); 711 | } 712 | if (bg) { 713 | styles.push(`background-color:rgb(${bg.rgb})`); 714 | } 715 | } else { 716 | // USE CLASSES 717 | 718 | // Note on bold: https://stackoverflow.com/questions/6737005/what-are-some-advantages-to-using-span-style-font-weightbold-rather-than-b?rq=1 719 | if (fragment.bold) { 720 | classes.push("ansi-bold"); 721 | } 722 | 723 | if (fragment.faint) { 724 | classes.push("ansi-faint"); 725 | } 726 | 727 | if (fragment.italic) { 728 | classes.push("ansi-italic"); 729 | } 730 | 731 | if (fragment.underline) { 732 | classes.push("ansi-underline"); 733 | } 734 | 735 | if (fg) { 736 | if (fg.class_name !== 'truecolor') { 737 | classes.push(`${fg.class_name}-fg`); 738 | } else { 739 | styles.push(`color:rgb(${fg.rgb.join(',')})`); 740 | } 741 | } 742 | if (bg) { 743 | if (bg.class_name !== 'truecolor') { 744 | classes.push(`${bg.class_name}-bg`); 745 | } else { 746 | styles.push(`background-color:rgb(${bg.rgb.join(',')})`); 747 | } 748 | } 749 | } 750 | 751 | let class_string = ''; 752 | let style_string = ''; 753 | 754 | if (classes.length) { 755 | class_string = ` class="${classes.join(' ')}"`; 756 | } 757 | 758 | if (styles.length) { 759 | style_string = ` style="${styles.join(';')}"`; 760 | } 761 | 762 | return `${txt}`; 763 | } 764 | 765 | private process_hyperlink(pkt: TextPacket): string { 766 | // Check URL scheme 767 | const parts = pkt.url.split(':'); 768 | if (parts.length < 1) { 769 | return ''; 770 | } 771 | 772 | if (!this._url_allowlist[parts[0]]) { 773 | return ''; 774 | } 775 | 776 | const result = `${this.escape_txt_for_html(pkt.text)}`; 777 | return result; 778 | } 779 | } 780 | 781 | // 782 | // PRIVATE FUNCTIONS 783 | // 784 | 785 | // ES5 template string transformer 786 | function rgx(tmplObj: any, ...subst: any) { 787 | // Use the 'raw' value so we don't have to double backslash in a template string 788 | const regexText: string = tmplObj.raw[0]; 789 | 790 | // Remove white-space and comments 791 | const wsrgx = /^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm; 792 | const txt2 = regexText.replace(wsrgx, ''); 793 | return new RegExp(txt2); 794 | } 795 | 796 | // ES5 template string transformer 797 | // Multi-Line On 798 | function rgxG(tmplObj: any, ...subst: any) { 799 | // Use the 'raw' value so we don't have to double backslash in a template string 800 | const regexText: string = tmplObj.raw[0]; 801 | 802 | // Remove white-space and comments 803 | const wsrgx = /^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm; 804 | const txt2 = regexText.replace(wsrgx, ''); 805 | return new RegExp(txt2, 'g'); 806 | } 807 | -------------------------------------------------------------------------------- /packages/module/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LogViewer'; 2 | -------------------------------------------------------------------------------- /packages/module/src/react-window/VariableSizeList.tsx: -------------------------------------------------------------------------------- 1 | import createListComponent from './createListComponent'; 2 | 3 | import { ScrollToAlign, ListProps, InstanceProps, ItemMetadata } from './createListComponent'; 4 | 5 | const DEFAULT_ESTIMATED_ITEM_SIZE = 50; 6 | 7 | const getItemMetadata = (props: ListProps, index: number, instanceProps: InstanceProps): ItemMetadata => { 8 | const { itemSize } = props; 9 | const { itemMetadataMap, lastMeasuredIndex } = instanceProps; 10 | 11 | if (index > lastMeasuredIndex) { 12 | let offset = 0; 13 | if (lastMeasuredIndex >= 0) { 14 | const itemMetadata = itemMetadataMap[lastMeasuredIndex]; 15 | offset = itemMetadata.offset + itemMetadata.size; 16 | } 17 | 18 | for (let i = lastMeasuredIndex + 1; i <= index; i++) { 19 | const size = typeof itemSize === 'number' ? itemSize : itemSize(i); 20 | 21 | itemMetadataMap[i] = { 22 | offset, 23 | size 24 | }; 25 | 26 | offset += size; 27 | } 28 | 29 | instanceProps.lastMeasuredIndex = index; 30 | } 31 | 32 | return itemMetadataMap[index]; 33 | }; 34 | 35 | const findNearestItem = (props: ListProps, instanceProps: InstanceProps, offset: number) => { 36 | const { itemMetadataMap, lastMeasuredIndex } = instanceProps; 37 | 38 | const lastMeasuredItemOffset = lastMeasuredIndex > 0 ? itemMetadataMap[lastMeasuredIndex].offset : 0; 39 | 40 | if (lastMeasuredItemOffset >= offset) { 41 | // If we've already measured items within this range just use a binary search as it's faster. 42 | return findNearestItemBinarySearch(props, instanceProps, lastMeasuredIndex, 0, offset); 43 | } else { 44 | // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. 45 | // The exponential search avoids pre-computing sizes for the full set of items as a binary search would. 46 | // The overall complexity for this approach is O(log n). 47 | return findNearestItemExponentialSearch(props, instanceProps, Math.max(0, lastMeasuredIndex), offset); 48 | } 49 | }; 50 | 51 | const findNearestItemBinarySearch = ( 52 | props: ListProps, 53 | instanceProps: InstanceProps, 54 | high: number, 55 | low: number, 56 | offset: number 57 | ): number => { 58 | while (low <= high) { 59 | const middle = low + Math.floor((high - low) / 2); 60 | const currentOffset = getItemMetadata(props, middle, instanceProps).offset; 61 | 62 | if (currentOffset === offset) { 63 | return middle; 64 | } else if (currentOffset < offset) { 65 | low = middle + 1; 66 | } else if (currentOffset > offset) { 67 | high = middle - 1; 68 | } 69 | } 70 | 71 | if (low > 0) { 72 | return low - 1; 73 | } else { 74 | return 0; 75 | } 76 | }; 77 | 78 | const findNearestItemExponentialSearch = ( 79 | props: ListProps, 80 | instanceProps: InstanceProps, 81 | index: number, 82 | offset: number 83 | ): number => { 84 | const { itemCount } = props; 85 | let interval = 1; 86 | 87 | while (index < itemCount && getItemMetadata(props, index, instanceProps).offset < offset) { 88 | index += interval; 89 | interval *= 2; 90 | } 91 | 92 | return findNearestItemBinarySearch( 93 | props, 94 | instanceProps, 95 | Math.min(index, itemCount - 1), 96 | Math.floor(index / 2), 97 | offset 98 | ); 99 | }; 100 | 101 | const getEstimatedTotalSize = ( 102 | { itemCount }: ListProps, 103 | { itemMetadataMap, estimatedItemSize, lastMeasuredIndex }: InstanceProps 104 | ) => { 105 | let totalSizeOfMeasuredItems = 0; 106 | 107 | // Edge case check for when the number of items decreases while a scroll is in progress. 108 | // https://github.com/bvaughn/react-window/pull/138 109 | if (lastMeasuredIndex >= itemCount) { 110 | lastMeasuredIndex = itemCount - 1; 111 | } 112 | 113 | if (lastMeasuredIndex >= 0) { 114 | const itemMetadata = itemMetadataMap[lastMeasuredIndex]; 115 | totalSizeOfMeasuredItems = itemMetadata.offset + itemMetadata.size; 116 | } 117 | 118 | const numUnmeasuredItems = itemCount - lastMeasuredIndex - 1; 119 | const totalSizeOfUnmeasuredItems = numUnmeasuredItems * estimatedItemSize; 120 | 121 | return totalSizeOfMeasuredItems + totalSizeOfUnmeasuredItems; 122 | }; 123 | 124 | export const VariableSizeList = createListComponent({ 125 | getItemOffset: (props: ListProps, index: number, instanceProps: InstanceProps): number => 126 | getItemMetadata(props, index, instanceProps).offset, 127 | 128 | getItemSize: (props: ListProps, index: number, instanceProps: InstanceProps): number => 129 | instanceProps.itemMetadataMap[index].size, 130 | 131 | getEstimatedTotalSize, 132 | 133 | getOffsetForIndexAndAlignment: ( 134 | props: ListProps, 135 | index: number, 136 | align: ScrollToAlign, 137 | scrollOffset: number, 138 | instanceProps: InstanceProps 139 | ): number => { 140 | const { height } = props; 141 | 142 | const size = height; 143 | const itemMetadata = getItemMetadata(props, index, instanceProps); 144 | 145 | // Get estimated total size after ItemMetadata is computed, 146 | // To ensure it reflects actual measurements instead of just estimates. 147 | const estimatedTotalSize = getEstimatedTotalSize(props, instanceProps); 148 | 149 | const maxOffset = Math.max(0, Math.min(estimatedTotalSize - size, itemMetadata.offset)); 150 | const minOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size); 151 | 152 | if (align === 'smart') { 153 | if (scrollOffset >= minOffset - size && scrollOffset <= maxOffset + size) { 154 | align = 'auto'; 155 | } else { 156 | align = 'center'; 157 | } 158 | } 159 | 160 | switch (align) { 161 | case 'start': 162 | return maxOffset; 163 | case 'end': 164 | return minOffset; 165 | case 'center': 166 | return Math.round(minOffset + (maxOffset - minOffset) / 2); 167 | case 'auto': 168 | default: 169 | if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { 170 | return scrollOffset; 171 | } else if (scrollOffset < minOffset) { 172 | return minOffset; 173 | } else { 174 | return maxOffset; 175 | } 176 | } 177 | }, 178 | 179 | getStartIndexForOffset: (props: ListProps, offset: number, instanceProps: InstanceProps): number => 180 | findNearestItem(props, instanceProps, offset), 181 | 182 | getStopIndexForStartIndex: ( 183 | props: ListProps, 184 | startIndex: number, 185 | scrollOffset: number, 186 | instanceProps: InstanceProps 187 | ): number => { 188 | const { height, itemCount } = props; 189 | 190 | const size = height; 191 | const itemMetadata = getItemMetadata(props, startIndex, instanceProps); 192 | const maxOffset = scrollOffset + size; 193 | 194 | let offset = itemMetadata.offset + itemMetadata.size; 195 | let stopIndex = startIndex; 196 | 197 | while (stopIndex < itemCount - 1 && offset < maxOffset) { 198 | stopIndex++; 199 | offset += getItemMetadata(props, stopIndex, instanceProps).size; 200 | } 201 | 202 | return stopIndex; 203 | }, 204 | 205 | initInstanceProps(props: ListProps, instance: any): InstanceProps { 206 | const { estimatedItemSize } = props; 207 | 208 | const instanceProps = { 209 | itemMetadataMap: {}, 210 | estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, 211 | lastMeasuredIndex: -1 212 | }; 213 | 214 | instance.resetAfterIndex = (index: number, shouldForceUpdate: boolean = true) => { 215 | instanceProps.lastMeasuredIndex = Math.min(instanceProps.lastMeasuredIndex, index - 1); 216 | 217 | // We could potentially optimize further by only evicting styles after this index, 218 | // But since styles are only cached while scrolling is in progress- 219 | // It seems an unnecessary optimization. 220 | // It's unlikely that resetAfterIndex() will be called while a user is scrolling. 221 | instance._getItemStyleCache(-1); 222 | 223 | if (shouldForceUpdate) { 224 | instance.forceUpdate(); 225 | } 226 | }; 227 | 228 | return instanceProps; 229 | }, 230 | 231 | shouldResetStyleCacheOnItemSizeChange: false, 232 | 233 | validateProps: ({ itemSize }: ListProps) => { 234 | if (process.env.NODE_ENV !== 'production') { 235 | if (typeof itemSize !== 'function') { 236 | throw Error( 237 | 'An invalid "itemSize" prop has been specified. ' + 238 | 'Value should be a function. ' + 239 | `"${itemSize === null ? 'null' : typeof itemSize}" was specified.` 240 | ); 241 | } 242 | } 243 | } 244 | }); 245 | -------------------------------------------------------------------------------- /packages/module/src/react-window/areEqual.ts: -------------------------------------------------------------------------------- 1 | import shallowDiffers from './shallowDiffers'; 2 | 3 | // Custom comparison function for React.memo(). 4 | // It knows to compare individual style props and ignore the wrapper object. 5 | // See https://reactjs.org/docs/react-api.html#reactmemo 6 | export function areEqual(prevProps: any, nextProps: any): boolean { 7 | const { style: prevStyle, ...prevRest } = prevProps; 8 | const { style: nextStyle, ...nextRest } = nextProps; 9 | 10 | return !shallowDiffers(prevStyle, nextStyle) && !shallowDiffers(prevRest, nextRest); 11 | } 12 | -------------------------------------------------------------------------------- /packages/module/src/react-window/createListComponent.ts: -------------------------------------------------------------------------------- 1 | import memoizeOne from 'memoize-one'; 2 | import { createElement, PureComponent } from 'react'; 3 | import { cancelTimeout, requestTimeout } from './timer'; 4 | 5 | import { TimeoutID } from './timer'; 6 | 7 | export type ScrollToAlign = 'auto' | 'smart' | 'center' | 'start' | 'end'; 8 | 9 | export interface RowProps { 10 | data: any; 11 | index: number; 12 | isScrolling?: boolean; 13 | style: React.CSSProperties; 14 | ansiUp: any; 15 | } 16 | 17 | export interface ListProps { 18 | estimatedItemSize?: number; 19 | children: React.FunctionComponent; 20 | [key: string]: any; 21 | } 22 | 23 | export interface ItemMetadata { 24 | offset: number; 25 | size: number; 26 | } 27 | 28 | export interface InstanceProps { 29 | itemMetadataMap: { [index: number]: ItemMetadata }; 30 | estimatedItemSize: number; 31 | lastMeasuredIndex: number; 32 | } 33 | 34 | type ScrollDirection = 'forward' | 'backward'; 35 | 36 | type onItemsRenderedCallback = ({ 37 | overscanStartIndex, 38 | overscanStopIndex, 39 | visibleStartIndex, 40 | visibleStopIndex 41 | }: { 42 | overscanStartIndex: number; 43 | overscanStopIndex: number; 44 | visibleStartIndex: number; 45 | visibleStopIndex: number; 46 | }) => void; 47 | 48 | type ScrollEvent = React.SyntheticEvent; 49 | 50 | export interface State { 51 | instance: any; 52 | isScrolling: boolean; 53 | scrollDirection: ScrollDirection; 54 | scrollOffset: number; 55 | scrollOffsetToBottom: number; 56 | scrollUpdateWasRequested: boolean; 57 | } 58 | 59 | type GetItemOffset = (props: ListProps, index: number, instanceProps?: any) => number; 60 | type GetItemSize = (props: ListProps, index?: number, instanceProps?: any) => number; 61 | type GetEstimatedTotalSize = (props: ListProps, instanceProps?: any) => number; 62 | type GetOffsetForIndexAndAlignment = ( 63 | props: ListProps, 64 | index: number, 65 | align: ScrollToAlign, 66 | scrollOffset: number, 67 | instanceProps?: any 68 | ) => number; 69 | type GetStartIndexForOffset = (props: ListProps, offset: number, instanceProps?: any) => number; 70 | type GetStopIndexForStartIndex = ( 71 | props: ListProps, 72 | startIndex: number, 73 | scrollOffset: number, 74 | instanceProps?: InstanceProps 75 | ) => number; 76 | type InitInstanceProps = (props?: ListProps, instance?: any) => any; 77 | type ValidateProps = (props: ListProps) => void; 78 | 79 | const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; 80 | 81 | const defaultItemKey = (index: number, _data: any) => index; 82 | 83 | // In DEV mode, this Set helps us only log a warning once per component instance. 84 | // This avoids spamming the console every time a render happens. 85 | let devWarningsTagName: WeakSet = null; 86 | if (process.env.NODE_ENV !== 'production') { 87 | if (typeof window !== 'undefined' && typeof window.WeakSet !== 'undefined') { 88 | devWarningsTagName = new WeakSet(); 89 | } 90 | } 91 | 92 | export default function createListComponent({ 93 | getItemOffset, 94 | getEstimatedTotalSize, 95 | getItemSize, 96 | getOffsetForIndexAndAlignment, 97 | getStartIndexForOffset, 98 | getStopIndexForStartIndex, 99 | initInstanceProps, 100 | shouldResetStyleCacheOnItemSizeChange, 101 | validateProps 102 | }: { 103 | getItemOffset: GetItemOffset; 104 | getEstimatedTotalSize: GetEstimatedTotalSize; 105 | getItemSize: GetItemSize; 106 | getOffsetForIndexAndAlignment: GetOffsetForIndexAndAlignment; 107 | getStartIndexForOffset: GetStartIndexForOffset; 108 | getStopIndexForStartIndex: GetStopIndexForStartIndex; 109 | initInstanceProps: InitInstanceProps; 110 | shouldResetStyleCacheOnItemSizeChange: boolean; 111 | validateProps: ValidateProps; 112 | }) { 113 | return class List extends PureComponent { 114 | _instanceProps = initInstanceProps(this.props, this); 115 | _outerRef?: HTMLDivElement; 116 | _resetIsScrollingTimeoutId: TimeoutID | null = null; 117 | 118 | static defaultProps = { 119 | itemData: undefined as any, 120 | overscanCount: 2, 121 | useIsScrolling: false 122 | }; 123 | 124 | state: State = { 125 | instance: this, 126 | isScrolling: false, 127 | scrollDirection: 'forward', 128 | scrollOffset: typeof this.props.initialScrollOffset === 'number' ? this.props.initialScrollOffset : 0, 129 | scrollOffsetToBottom: -1, 130 | scrollUpdateWasRequested: false 131 | }; 132 | 133 | // Always use explicit constructor for React components. 134 | // It produces less code after transpilation. (#26) 135 | // eslint-disable-next-line no-useless-constructor 136 | constructor(props: ListProps) { 137 | super(props); 138 | } 139 | 140 | static getDerivedStateFromProps(nextProps: ListProps, prevState: State): State | null { 141 | validateSharedProps(nextProps, prevState); 142 | validateProps(nextProps); 143 | return null; 144 | } 145 | 146 | scrollTo(scrollOffset: number): void { 147 | scrollOffset = Math.max(0, scrollOffset); 148 | 149 | this.setState(prevState => { 150 | if (prevState.scrollOffset === scrollOffset) { 151 | return null; 152 | } 153 | return { 154 | scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward', 155 | scrollOffset, 156 | scrollUpdateWasRequested: true 157 | }; 158 | }, this._resetIsScrollingDebounced); 159 | } 160 | 161 | scrollToItem(index: number, align: ScrollToAlign = 'auto'): void { 162 | const { itemCount } = this.props; 163 | const { scrollOffset } = this.state; 164 | 165 | index = Math.max(0, Math.min(index, itemCount - 1)); 166 | 167 | this.scrollTo(getOffsetForIndexAndAlignment(this.props, index, align, scrollOffset, this._instanceProps)); 168 | } 169 | 170 | scrollToBottom(): void { 171 | const outerRef = this._outerRef as HTMLElement; 172 | const { scrollHeight, clientHeight } = outerRef; 173 | 174 | this.scrollTo(scrollHeight - clientHeight); 175 | } 176 | 177 | onTextSelectionStart(): void { 178 | if (this._outerRef) { 179 | this._outerRef.style.overflowY = 'hidden'; 180 | } 181 | } 182 | 183 | onTextSelectionStop(): void { 184 | if (this._outerRef) { 185 | this._outerRef.style.overflowY = 'auto'; 186 | } 187 | } 188 | 189 | componentDidMount() { 190 | const { initialScrollOffset } = this.props; 191 | 192 | if (typeof initialScrollOffset === 'number' && this._outerRef != null) { 193 | const outerRef = this._outerRef as HTMLElement; 194 | outerRef.scrollTop = initialScrollOffset; 195 | } 196 | const innerRef = this._outerRef.firstChild; // innerRef will be 'pf-v5-c-log-viewer__list' 197 | ['mousedown', 'touchstart'].forEach(event => { 198 | innerRef.addEventListener(event, this.onTextSelectionStart.bind(this)); 199 | }); 200 | // set mouseup event listener on the whole document 201 | // because the cursor could be out side of the log window when the mouse is up 202 | // in that case the window would not be able to scroll up and down because overflow-Y is not set back to 'auto' 203 | ['mouseup', 'touchend'].forEach(event => { 204 | document.addEventListener(event, this.onTextSelectionStop.bind(this)); 205 | }); 206 | this._callPropsCallbacks(); 207 | } 208 | 209 | componentDidUpdate() { 210 | const { scrollOffset, scrollUpdateWasRequested } = this.state; 211 | 212 | if (scrollUpdateWasRequested && this._outerRef != null) { 213 | const outerRef = this._outerRef as HTMLElement; 214 | outerRef.scrollTop = scrollOffset; 215 | } 216 | 217 | this._callPropsCallbacks(); 218 | } 219 | 220 | componentWillUnmount() { 221 | if (this._resetIsScrollingTimeoutId !== null) { 222 | cancelTimeout(this._resetIsScrollingTimeoutId); 223 | } 224 | const innerRef = this._outerRef.firstChild; // innerRef will be 'pf-v5-c-log-viewer__list' 225 | ['mousedown', 'touchstart'].forEach(event => { 226 | innerRef.removeEventListener(event, this.onTextSelectionStart.bind(this)); 227 | }); 228 | ['mouseup', 'touchend'].forEach(event => { 229 | document.removeEventListener(event, this.onTextSelectionStop.bind(this)); 230 | }); 231 | } 232 | 233 | render() { 234 | const { 235 | children, 236 | outerClassName, 237 | innerClassName, 238 | height, 239 | innerRef, 240 | innerElementType, 241 | innerTagName, 242 | itemCount, 243 | itemData, 244 | itemKey = defaultItemKey, 245 | outerElementType, 246 | outerTagName, 247 | style, 248 | useIsScrolling, 249 | width, 250 | isTextWrapped, 251 | hasLineNumbers, 252 | indexWidth, 253 | ansiUp 254 | } = this.props; 255 | const { isScrolling } = this.state; 256 | 257 | const onScroll = this._onScrollVertical; 258 | 259 | const [startIndex, stopIndex] = this._getRangeToRender(); 260 | 261 | const items = []; 262 | if (itemCount > 0) { 263 | for (let index = startIndex; index <= stopIndex; index++) { 264 | items.push( 265 | createElement(children, { 266 | data: itemData, 267 | key: itemKey(index, itemData), 268 | index, 269 | isScrolling: useIsScrolling ? isScrolling : undefined, 270 | style: this._getItemStyle(index), 271 | ansiUp 272 | }) 273 | ); 274 | } 275 | } 276 | 277 | // Read this value AFTER items have been created, 278 | // So their actual sizes (if variable) are taken into consideration. 279 | const estimatedTotalSize = getEstimatedTotalSize(this.props, this._instanceProps); 280 | 281 | return createElement( 282 | outerElementType || outerTagName || 'div', 283 | { 284 | className: outerClassName, 285 | onScroll, 286 | ref: this._outerRefSetter, 287 | tabIndex: 0, 288 | style: { 289 | height, 290 | paddingTop: 0, 291 | paddingBottom: 0, 292 | WebkitOverflowScrolling: 'touch', 293 | overflowX: isTextWrapped ? 'hidden' : 'auto', 294 | ...style 295 | } 296 | }, 297 | createElement( 298 | innerElementType || innerTagName || 'div', 299 | { 300 | className: innerClassName, 301 | ref: innerRef, 302 | style: { 303 | height: estimatedTotalSize > height ? estimatedTotalSize : height, 304 | /* eslint-disable-next-line no-nested-ternary */ 305 | width: isTextWrapped ? (hasLineNumbers ? width - indexWidth : width) : 'auto', 306 | pointerEvents: isScrolling ? 'none' : undefined 307 | } 308 | }, 309 | items 310 | ) 311 | ); 312 | } 313 | 314 | _callOnItemsRendered = memoizeOne( 315 | (overscanStartIndex: number, overscanStopIndex: number, visibleStartIndex: number, visibleStopIndex: number) => 316 | (this.props.onItemsRendered as onItemsRenderedCallback)({ 317 | overscanStartIndex, 318 | overscanStopIndex, 319 | visibleStartIndex, 320 | visibleStopIndex 321 | }) 322 | ); 323 | 324 | _callOnScroll = memoizeOne( 325 | ( 326 | scrollDirection: ScrollDirection, 327 | scrollOffset: number, 328 | scrollOffsetToBottom: number, 329 | scrollUpdateWasRequested: boolean 330 | ) => 331 | this.props.onScroll({ 332 | scrollDirection, 333 | scrollOffset, 334 | scrollOffsetToBottom, 335 | scrollUpdateWasRequested 336 | }) 337 | ); 338 | 339 | _callPropsCallbacks() { 340 | if (typeof this.props.onItemsRendered === 'function') { 341 | const { itemCount } = this.props; 342 | if (itemCount > 0) { 343 | const [overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex] = this._getRangeToRender(); 344 | this._callOnItemsRendered(overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex); 345 | } 346 | } 347 | 348 | if (typeof this.props.onScroll === 'function') { 349 | const { scrollDirection, scrollOffset, scrollOffsetToBottom, scrollUpdateWasRequested } = this.state; 350 | this._callOnScroll(scrollDirection, scrollOffset, scrollOffsetToBottom, scrollUpdateWasRequested); 351 | } 352 | } 353 | 354 | // Lazily create and cache item styles while scrolling, 355 | // So that pure component sCU will prevent re-renders. 356 | // We maintain this cache, and pass a style prop rather than index, 357 | // So that List can clear cached styles and force item re-render if necessary. 358 | _getItemStyle = (index: number): Object => { 359 | const { itemSize } = this.props; 360 | 361 | const itemStyleCache = this._getItemStyleCache(shouldResetStyleCacheOnItemSizeChange && itemSize); 362 | 363 | let style; 364 | // eslint-disable-next-line no-prototype-builtins 365 | if (itemStyleCache.hasOwnProperty(index)) { 366 | style = itemStyleCache[index]; 367 | } else { 368 | const offset = getItemOffset(this.props, index, this._instanceProps); 369 | const size = getItemSize(this.props, index, this._instanceProps); 370 | 371 | itemStyleCache[index] = style = { 372 | position: 'absolute', 373 | top: offset, 374 | height: size 375 | }; 376 | } 377 | 378 | return style; 379 | }; 380 | 381 | _getItemStyleCache = memoizeOne(() => ({})) as (itemSize?: any) => { [key: number]: Object }; 382 | 383 | _getRangeToRender(): [number, number, number, number] { 384 | const { itemCount, overscanCount } = this.props; 385 | const { isScrolling, scrollDirection, scrollOffset } = this.state; 386 | 387 | if (itemCount === 0) { 388 | return [0, 0, 0, 0]; 389 | } 390 | 391 | const startIndex = getStartIndexForOffset(this.props, scrollOffset, this._instanceProps); 392 | const stopIndex = getStopIndexForStartIndex(this.props, startIndex, scrollOffset, this._instanceProps); 393 | 394 | // Overscan by one item in each direction so that tab/focus works. 395 | // If there isn't at least one extra item, tab loops back around. 396 | const overscanBackward = !isScrolling || scrollDirection === 'backward' ? Math.max(1, overscanCount) : 1; 397 | const overscanForward = !isScrolling || scrollDirection === 'forward' ? Math.max(1, overscanCount) : 1; 398 | 399 | return [ 400 | Math.max(0, startIndex - overscanBackward), 401 | Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)), 402 | startIndex, 403 | stopIndex 404 | ]; 405 | } 406 | 407 | _onScrollVertical = (event: ScrollEvent): void => { 408 | const { clientHeight, scrollHeight, scrollTop } = event.currentTarget; 409 | this.setState(prevState => { 410 | if (prevState.scrollOffset === scrollTop) { 411 | // Scroll position may have been updated by cDM/cDU, 412 | // In which case we don't need to trigger another render, 413 | // And we don't want to update state.isScrolling. 414 | return null; 415 | } 416 | 417 | // Prevent Safari's elastic scrolling from causing visual shaking when scrolling past bounds. 418 | const scrollOffset = Math.max(0, Math.min(scrollTop, scrollHeight - clientHeight)); 419 | 420 | const scrollOffsetToBottom = scrollHeight - scrollTop - clientHeight; 421 | 422 | return { 423 | isScrolling: true, 424 | scrollDirection: prevState.scrollOffset < scrollOffset ? 'forward' : 'backward', 425 | scrollOffset, 426 | scrollOffsetToBottom, 427 | scrollUpdateWasRequested: false 428 | }; 429 | }, this._resetIsScrollingDebounced); 430 | }; 431 | 432 | _outerRefSetter = (ref: any): void => { 433 | const { outerRef } = this.props; 434 | 435 | this._outerRef = ref; 436 | 437 | if (typeof outerRef === 'function') { 438 | outerRef(ref); 439 | // eslint-disable-next-line no-prototype-builtins 440 | } else if (outerRef != null && typeof outerRef === 'object' && outerRef.hasOwnProperty('current')) { 441 | outerRef.current = ref; 442 | } 443 | }; 444 | 445 | _resetIsScrollingDebounced = () => { 446 | if (this._resetIsScrollingTimeoutId !== null) { 447 | cancelTimeout(this._resetIsScrollingTimeoutId); 448 | } 449 | 450 | this._resetIsScrollingTimeoutId = requestTimeout(this._resetIsScrolling, IS_SCROLLING_DEBOUNCE_INTERVAL); 451 | }; 452 | 453 | _resetIsScrolling = () => { 454 | this._resetIsScrollingTimeoutId = null; 455 | 456 | this.setState({ isScrolling: false }, () => { 457 | // Clear style cache after state update has been committed. 458 | // This way we don't break pure sCU for items that don't use isScrolling param. 459 | this._getItemStyleCache(-1); 460 | }); 461 | }; 462 | }; 463 | } 464 | 465 | // NOTE: I considered further wrapping individual items with a pure ListItem component. 466 | // This would avoid ever calling the render function for the same index more than once, 467 | // But it would also add the overhead of a lot of components/fibers. 468 | // I assume people already do this (render function returning a class component), 469 | // So my doing it would just unnecessarily double the wrappers. 470 | 471 | const validateSharedProps = ({ children, innerTagName, outerTagName }: ListProps, { instance }: State): void => { 472 | if (process.env.NODE_ENV !== 'production') { 473 | if (innerTagName != null || outerTagName != null) { 474 | if (devWarningsTagName && !devWarningsTagName.has(instance)) { 475 | devWarningsTagName.add(instance); 476 | // eslint-disable-next-line no-console 477 | console.warn( 478 | 'The innerTagName and outerTagName props have been deprecated. ' + 479 | 'Please use the innerElementType and outerElementType props instead.' 480 | ); 481 | } 482 | } 483 | 484 | if (children == null) { 485 | throw Error( 486 | 'An invalid "children" prop has been specified. ' + 487 | 'Value should be a React component. ' + 488 | `"${children === null ? 'null' : typeof children}" was specified.` 489 | ); 490 | } 491 | } 492 | }; 493 | -------------------------------------------------------------------------------- /packages/module/src/react-window/index.ts: -------------------------------------------------------------------------------- 1 | export { areEqual } from './areEqual'; 2 | export { VariableSizeList } from './VariableSizeList'; 3 | -------------------------------------------------------------------------------- /packages/module/src/react-window/shallowDiffers.ts: -------------------------------------------------------------------------------- 1 | // Pulled from react-compat 2 | // https://github.com/developit/preact-compat/blob/7c5de00e7c85e2ffd011bf3af02899b63f699d3a/src/index.js#L349 3 | export default function shallowDiffers(prev: any, next: any): boolean { 4 | for (const attribute in prev) { 5 | if (!(attribute in next)) { 6 | return true; 7 | } 8 | } 9 | for (const attribute in next) { 10 | if (prev[attribute] !== next[attribute]) { 11 | return true; 12 | } 13 | } 14 | return false; 15 | } 16 | -------------------------------------------------------------------------------- /packages/module/src/react-window/timer.ts: -------------------------------------------------------------------------------- 1 | // Animation frame based implementation of setTimeout. 2 | // Inspired by Joe Lambert, https://gist.github.com/joelambert/1002116#file-requesttimeout-js 3 | 4 | const hasNativePerformanceNow = typeof performance === 'object' && typeof performance.now === 'function'; 5 | 6 | const now = hasNativePerformanceNow ? () => performance.now() : () => Date.now(); 7 | 8 | export interface TimeoutID { 9 | id: number; 10 | } 11 | 12 | export function cancelTimeout(timeoutID: TimeoutID) { 13 | cancelAnimationFrame(timeoutID.id); 14 | } 15 | 16 | export function requestTimeout(callback: Function, delay: number): TimeoutID { 17 | const start = now(); 18 | 19 | function tick() { 20 | if (now() - start >= delay) { 21 | callback.call(null); 22 | } else { 23 | timeoutID.id = requestAnimationFrame(tick); 24 | } 25 | } 26 | 27 | const timeoutID: TimeoutID = { 28 | id: requestAnimationFrame(tick) 29 | }; 30 | 31 | return timeoutID; 32 | } 33 | -------------------------------------------------------------------------------- /packages/module/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/js", 5 | "module": "commonjs", 6 | "tsBuildInfoFile": "dist/cjs.tsbuildinfo" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/module/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | "assumeChangesOnlyAffectDirectDependencies": true, 7 | "incremental": true /* Enable incremental compilation */, 8 | "target": "es2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 9 | "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 10 | "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */, 11 | "lib": ["es2015", "dom"], 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 14 | "sourceMap": true /* Generates corresponding '.map' file. */, 15 | "outDir": "./dist/esm" /* Redirect output structure to the directory. */, 16 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 17 | "strict": true /* Enable all strict type-checking options. */, 18 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 19 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 20 | "skipLibCheck": true /* Skip type checking of declaration files. */, 21 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 22 | "strictNullChecks": false, 23 | "isolatedModules": true, 24 | "importHelpers": true, 25 | "composite": true, 26 | "plugins": [{ "transform": "transformer-cjs-imports" }], 27 | "tsBuildInfoFile": "dist/esm.tsbuildinfo" 28 | }, 29 | "include": ["./src/*", "./src/**/*", "tsconfig.cjs.json"], 30 | "exclude": ["**/**.test.tsx", "**/**.test.ts", "**/examples/**", "**/__mocks__/**"] 31 | } 32 | -------------------------------------------------------------------------------- /packages/transformer-cjs-imports/index.js: -------------------------------------------------------------------------------- 1 | // https://levelup.gitconnected.com/writing-typescript-custom-ast-transformer-part-2-5322c2b1660e 2 | const ts = require('typescript'); 3 | 4 | /** 5 | * We import `@patternfly/react-[tokens,icons]/dist/esm` to avoid parsing massive modules. 6 | * HOWEVER we would like for the CJS output to reference `@patternfly/react-[]/dist/cjs` 7 | * for better tree-shaking and smaller bundlers. 8 | * A large offender of this is Tooltip's Popover helper. 9 | * 10 | * @param {object} context TS context 11 | */ 12 | function transformerCJSImports(context) { 13 | // Only transform for CJS build 14 | // ESM: module = 5, CJS: module = 1 15 | if (context.getCompilerOptions().module !== 1) { 16 | return node => node; 17 | } 18 | /** 19 | * If a node is an import, change its moduleSpecifier 20 | * Otherwise iterate over all its childern. 21 | * 22 | * @param {object} node TS Node 23 | */ 24 | function visit(node) { 25 | if (ts.isImportDeclaration(node) && /@patternfly\/.*\/dist\/esm/.test(node.moduleSpecifier.text)) { 26 | const newNode = ts.getMutableClone(node); 27 | const newPath = node.moduleSpecifier.text.replace(/dist\/esm/, 'dist/js'); 28 | newNode.moduleSpecifier = ts.createStringLiteral(newPath, true); 29 | return newNode; 30 | } 31 | return ts.visitEachChild(node, child => visit(child), context); 32 | } 33 | return node => ts.visitNode(node, visit); 34 | } 35 | 36 | module.exports = () => ({ 37 | before: transformerCJSImports 38 | }); 39 | -------------------------------------------------------------------------------- /packages/transformer-cjs-imports/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transformer-cjs-imports", 3 | "private": true, 4 | "version": "4.79.3", 5 | "description": "Transform CJS imports to ESM in typescript depending on module target.", 6 | "main": "index.js", 7 | "author": "Red Hat", 8 | "license": "MIT", 9 | "peerDependencies": { 10 | "typescript": ">=3.7.2" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "packagePatterns": ["*"], 9 | "excludePackagePatterns": [ 10 | "@patternfly/documentation-framework" 11 | ], 12 | "enabled": false 13 | }, 14 | { 15 | "packageNames": [ 16 | "@patternfly/documentation-framework" 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /styleMock.js: -------------------------------------------------------------------------------- 1 | // We aren't actually using CSS modules, so we don't need to mock what `require('@patternfly/react-styles/css/whatever')` would do 2 | module.exports = {}; 3 | --------------------------------------------------------------------------------