├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.yaml │ ├── FEATURE_REQUEST.yaml │ └── QUESTION.yaml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── check.yaml │ ├── e2e.yaml │ ├── release.yaml │ ├── stale.yaml │ └── unittest.yaml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .tool-versions ├── HISTORY.md ├── LICENSE ├── README.md ├── e2e ├── basic.spec.ts ├── event.spec.ts ├── formula.spec.ts ├── playwright.config.ts ├── search.spec.ts ├── time.spec.ts └── utils.ts ├── eslint.config.js ├── gridsheet.png ├── package.json ├── packages ├── react-core │ ├── README.md │ ├── components │ │ ├── Cell.tsx │ │ ├── ContextMenu.tsx │ │ ├── Editor.tsx │ │ ├── Emitter.tsx │ │ ├── Fixed.tsx │ │ ├── FormulaBar.tsx │ │ ├── GridSheet.tsx │ │ ├── HeaderCellLeft.tsx │ │ ├── HeaderCellTop.tsx │ │ ├── PluginBase.tsx │ │ ├── Resizer.tsx │ │ ├── SearchBar.tsx │ │ ├── SheetProvider.tsx │ │ ├── StoreInitializer.tsx │ │ ├── Tabular.tsx │ │ ├── Toggle.tsx │ │ ├── hooks.ts │ │ └── svg │ │ │ ├── AddIcon.tsx │ │ │ ├── Base.tsx │ │ │ ├── CloseIcon.tsx │ │ │ └── SearchIcon.tsx │ ├── constants.ts │ ├── formula │ │ ├── evaluator.ts │ │ ├── functions │ │ │ ├── __base.ts │ │ │ ├── __utils.ts │ │ │ ├── abs.spec.ts │ │ │ ├── abs.ts │ │ │ ├── acos.ts │ │ │ ├── add.ts │ │ │ ├── and.ts │ │ │ ├── asin.ts │ │ │ ├── atan.ts │ │ │ ├── atan2.ts │ │ │ ├── average.ts │ │ │ ├── col.spec.ts │ │ │ ├── col.ts │ │ │ ├── concat.ts │ │ │ ├── concatenate.ts │ │ │ ├── cos.ts │ │ │ ├── count.ts │ │ │ ├── counta.ts │ │ │ ├── countif.spec.ts │ │ │ ├── countif.ts │ │ │ ├── divide.ts │ │ │ ├── eq.spec.ts │ │ │ ├── eq.ts │ │ │ ├── exp.ts │ │ │ ├── gt.ts │ │ │ ├── gte.ts │ │ │ ├── hlookup.ts │ │ │ ├── if.ts │ │ │ ├── iferror.spec.ts │ │ │ ├── iferror.ts │ │ │ ├── len.ts │ │ │ ├── lenb.ts │ │ │ ├── ln.ts │ │ │ ├── log.ts │ │ │ ├── log10.ts │ │ │ ├── lt.ts │ │ │ ├── lte.ts │ │ │ ├── max.ts │ │ │ ├── min.ts │ │ │ ├── minus.ts │ │ │ ├── mod.spec.ts │ │ │ ├── mod.ts │ │ │ ├── multiply.ts │ │ │ ├── ne.ts │ │ │ ├── not.ts │ │ │ ├── now.ts │ │ │ ├── or.ts │ │ │ ├── pi.ts │ │ │ ├── power.ts │ │ │ ├── product.ts │ │ │ ├── radians.ts │ │ │ ├── rand.ts │ │ │ ├── round.ts │ │ │ ├── rounddown.ts │ │ │ ├── roundup.ts │ │ │ ├── row.spec.ts │ │ │ ├── row.ts │ │ │ ├── sin.ts │ │ │ ├── sqrt.ts │ │ │ ├── sum.spec.ts │ │ │ ├── sum.ts │ │ │ ├── sumif.ts │ │ │ ├── tan.ts │ │ │ ├── uminus.ts │ │ │ └── vlookup.ts │ │ ├── mapping.ts │ │ └── solver.ts │ ├── generate-style.js │ ├── index.ts │ ├── jest.config.js │ ├── lib │ │ ├── autofill.ts │ │ ├── clipboard.ts │ │ ├── converters.ts │ │ ├── input.ts │ │ ├── palette.ts │ │ ├── prevention.ts │ │ ├── structs.ts │ │ ├── table.ts │ │ ├── time.ts │ │ └── virtualization.ts │ ├── package.json │ ├── parsers │ │ └── core.ts │ ├── renderers │ │ ├── checkbox.tsx │ │ ├── core.ts │ │ └── thousand_separator.ts │ ├── store │ │ ├── actions.ts │ │ ├── helpers.ts │ │ └── index.ts │ ├── styles │ │ ├── cell.less │ │ ├── contextmenu.less │ │ ├── editor.less │ │ ├── embedder.ts │ │ ├── minified.ts │ │ ├── root.less │ │ ├── root.min.css │ │ ├── search.less │ │ ├── tabular.less │ │ ├── theme-dark.less │ │ └── theme-light.less │ ├── tsconfig.json │ ├── types.ts │ ├── utils.ts │ └── vite.config.js ├── react-right-menu │ ├── README.md │ ├── gridsheet-right-menu.png │ ├── index.ts │ ├── package.json │ ├── right-menu.tsx │ ├── style.ts │ ├── tsconfig.json │ └── vite.config.js └── storybook │ ├── .storybook │ ├── main.ts │ ├── preview.js │ └── vitest.setup.js │ ├── package.json │ ├── stories │ ├── basic │ │ ├── labeler.stories.tsx │ │ ├── parser.stories.tsx │ │ ├── renderer.stories.tsx │ │ ├── resize.stories.tsx │ │ ├── sheets.stories.tsx │ │ ├── showAddress.stories.tsx │ │ ├── size.stories.tsx │ │ ├── style.stories.tsx │ │ └── theme.stories.tsx │ ├── demo │ │ ├── fruits.stories.tsx │ │ └── list.stories.tsx │ ├── events │ │ ├── onchange.stories.tsx │ │ ├── replace.stories.tsx │ │ ├── update.stories.tsx │ │ └── write.stories.tsx │ ├── formula │ │ ├── col.stories.tsx │ │ ├── custom.stories.tsx │ │ ├── disabled.stories.tsx │ │ ├── lookup.stories.tsx │ │ ├── no_formula_bar.stories.tsx │ │ ├── row.stories.tsx │ │ └── simple.stories.tsx │ ├── plugin │ │ └── menu.stories.tsx │ └── protection │ │ └── protection.stories.tsx │ ├── tsconfig.json │ └── vitest.config.js ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | 4 | [*.ts] 5 | indent_style = space 6 | indent_size = 2 7 | 8 | [*.js] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.json] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.yaml,*.yml] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.ts,*.tsx] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.scss,*.css,*.styl] 25 | indent_style = space 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.yaml: -------------------------------------------------------------------------------- 1 | name: "🐛 Bug Report" 2 | description: Create a new ticket for a bug. 3 | title: "🐛 [BUG] - " 4 | labels: [ 5 | "bug" 6 | ] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: "Description" 12 | description: Please enter an explicit description of your issue 13 | placeholder: Short and explicit description of your incident... 14 | validations: 15 | required: true 16 | - type: input 17 | id: reprod-url 18 | attributes: 19 | label: "Reproduction URL" 20 | description: Please enter your GitHub URL to provide a reproduction of the issue 21 | placeholder: ex. https://github.com/USERNAME/REPO-NAME 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: reprod 26 | attributes: 27 | label: "Reproduction steps" 28 | description: Please enter an explicit description of your issue 29 | value: | 30 | 1. Go to '...' 31 | 2. Click on '....' 32 | 3. Scroll down to '....' 33 | 4. See error 34 | render: bash 35 | validations: 36 | required: true 37 | - type: input 38 | id: version 39 | attributes: 40 | label: "Version" 41 | description: What version of the gridsheet are you using ? 42 | placeholder: ex. 1.0.0 43 | validations: 44 | required: true 45 | - type: textarea 46 | id: screenshot 47 | attributes: 48 | label: "Screenshots" 49 | description: If applicable, add screenshots to help explain your problem. 50 | value: | 51 | ![DESCRIPTION](LINK.png) 52 | render: bash 53 | validations: 54 | required: false 55 | - type: textarea 56 | id: logs 57 | attributes: 58 | label: "Logs" 59 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 60 | render: bash 61 | validations: 62 | required: false 63 | - type: dropdown 64 | id: browsers 65 | attributes: 66 | label: "Browsers" 67 | description: What browsers are you seeing the problem on ? 68 | multiple: true 69 | options: 70 | - Firefox 71 | - Chrome 72 | - Safari 73 | - Microsoft Edge 74 | - Opera 75 | validations: 76 | required: false 77 | - type: dropdown 78 | id: os 79 | attributes: 80 | label: "OS" 81 | description: What is the impacted environment ? 82 | multiple: true 83 | options: 84 | - Windows 85 | - Linux 86 | - Mac 87 | validations: 88 | required: false 89 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yaml: -------------------------------------------------------------------------------- 1 | name: "💡 Feature Request" 2 | description: Create a new ticket for a new feature request 3 | title: "💡 [REQUEST] - <title>" 4 | labels: [ 5 | "question" 6 | ] 7 | body: 8 | - type: textarea 9 | id: summary 10 | attributes: 11 | label: "Summary" 12 | description: Provide a brief explanation of the feature 13 | placeholder: Describe in a few lines your feature request 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: reference_issues 18 | attributes: 19 | label: "Reference Issues" 20 | description: Common issues 21 | placeholder: "#Issues IDs" 22 | validations: 23 | required: false 24 | - type: textarea 25 | id: basic_example 26 | attributes: 27 | label: "Basic Example" 28 | description: Indicate here some basic examples of your feature. 29 | placeholder: A few specific words about your feature request. 30 | validations: 31 | required: true 32 | - type: textarea 33 | id: drawbacks 34 | attributes: 35 | label: "Drawbacks" 36 | description: What are the drawbacks/impacts of your feature request ? 37 | placeholder: Identify the drawbacks and impacts while being neutral on your feature request 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: unresolved_question 42 | attributes: 43 | label: "Unresolved questions" 44 | description: What questions still remain unresolved ? 45 | placeholder: Identify any unresolved issues. 46 | validations: 47 | required: false 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/QUESTION.yaml: -------------------------------------------------------------------------------- 1 | name: "❓ Question" 2 | description: Create a new ticket for a question. 3 | title: "❓ [QUESTION] - <title>" 4 | labels: [ 5 | "question" 6 | ] 7 | body: 8 | - type: textarea 9 | id: description 10 | attributes: 11 | label: "Description" 12 | description: Please enter an explicit description of your question 13 | placeholder: Short and explicit description of your question... 14 | validations: 15 | required: true 16 | - type: input 17 | id: reprod-url 18 | attributes: 19 | label: "Reproduction URL" 20 | description: Please enter your GitHub URL to provide a reproduction of the question 21 | placeholder: ex. 22 | - type: input 23 | id: version 24 | attributes: 25 | label: "Version" 26 | description: What version of the gridsheet are you using ? 27 | placeholder: ex. 1.0.0 28 | validations: 29 | required: true 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | Please include a summary of the change or the feature being introduced. Detail how it addresses the issue at hand, and why this approach was chosen. 3 | 4 | ## Type of Change 5 | - [ ] Bug fix 6 | - [ ] Improvement 7 | - [ ] New feature 8 | - [ ] Breaking change 9 | - [ ] Reword 10 | - [ ] The other 11 | 12 | ### Impact Area 13 | Please describe the areas of the project that may be affected by your changes. This can include specific files, components, or functionalities. Highlight any areas that reviewers should pay special attention to. 14 | 15 | ## How Has This Been Tested? 16 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. 17 | 18 | - [ ] Visual operation check - `pnpm storybook` 19 | - [ ] Test - `pnpm test` 20 | - [ ] Lint - `pnpm eslint:fix` 21 | 22 | ## Additional Context 23 | Add any other context or screenshots about the pull request here. 24 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: typecheck 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | type-check: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: setup pnpm 13 | uses: pnpm/action-setup@v4 14 | 15 | - name: cache 16 | uses: actions/cache@v3 17 | with: 18 | path: | 19 | node_modules 20 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 21 | 22 | - name: install 23 | run: | 24 | pnpm install 25 | - name: check 26 | run: | 27 | pnpm typecheck:all 28 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | env: 7 | TZ: 'Asia/Tokyo' 8 | 9 | jobs: 10 | run-e2e-tests: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: setup python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.13" 19 | 20 | - name: setup pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: cache1 24 | uses: actions/cache@v3 25 | with: 26 | path: | 27 | node_modules 28 | key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }} 29 | 30 | - name: cache2 31 | uses: actions/cache@v3 32 | with: 33 | path: | 34 | storybook/node_modules 35 | key: ${{ runner.os }}-${{ hashFiles('./storybook/pnpm-lock.yaml') }} 36 | 37 | - name: install 38 | run: | 39 | pnpm install 40 | npx playwright install --with-deps 41 | 42 | - name: server 43 | run: | 44 | cd packages/storybook 45 | pnpm install 46 | pnpm dev & 47 | 48 | - name: e2e test 49 | run: pnpm e2e 50 | 51 | - uses: actions/upload-artifact@v4 52 | if: failure() 53 | with: 54 | name: playwright-results 55 | path: test-results 56 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - fix/* 7 | 8 | jobs: 9 | package-release: 10 | # needs: test 11 | runs-on: ubuntu-latest 12 | if: github.ref == 'refs/heads/master' 13 | name: npm upload 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: setup pnpm 17 | uses: pnpm/action-setup@v4 18 | 19 | - name: cache 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | node_modules 24 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 25 | 26 | - name: upload react-core 27 | run: | 28 | cd packages/react-core 29 | pnpm install 30 | pnpm build --mode development 31 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 32 | npm publish --access public || true 33 | 34 | - name: upload react-right-menu 35 | run: | 36 | cd packages/react-right-menu 37 | pnpm install 38 | pnpm build --mode development 39 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc 40 | npm publish || true 41 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '0 1 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 13 | days-before-stale: 30 14 | days-before-close: 5 15 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yaml: -------------------------------------------------------------------------------- 1 | name: unittest 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | env: 7 | TZ: 'Asia/Tokyo' 8 | 9 | jobs: 10 | run-unit-tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: setup pnpm 15 | uses: pnpm/action-setup@v4 16 | - name: cache 17 | uses: actions/cache@v3 18 | with: 19 | path: | 20 | node_modules 21 | key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 22 | - name: install 23 | run: | 24 | pnpm install 25 | - name: test 26 | run: | 27 | pnpm jest 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | dist 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Storybook 107 | storybook-static/ 108 | .idea 109 | 110 | # Playwright 111 | test-results/ 112 | 113 | *storybook.log 114 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.spec.ts 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "bracketSpacing": true, 8 | "arrowParens": "always" 9 | } -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.13.1 2 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | See [here](https://docs.walkframe.com/gridsheet/history). 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![unittest workflow](https://github.com/walkframe/gridsheet/actions/workflows/unittest.yaml/badge.svg?branch=master) 2 | ![e2e workflow](https://github.com/walkframe/gridsheet/actions/workflows/e2e.yaml/badge.svg?branch=master) 3 | 4 | ![gridsheet](https://github.com/walkframe/gridsheet/raw/master/gridsheet.png) 5 | 6 | ## Packages 7 | - [@gridsheet/react-core](https://github.com/walkframe/gridsheet/tree/master/packages/react-core) 8 | - [@gridsheet/react-right-menu](https://github.com/walkframe/gridsheet/tree/master/packages/react-right-menu) 9 | 10 | ## Docs 11 | 12 | - [ReactGridsheet document](https://docs.walkframe.com/gridsheet/react) 13 | - [Examples](https://docs.walkframe.com/gridsheet/Examples/react-case1) 14 | - [Histories](https://docs.walkframe.com/gridsheet/history) 15 | 16 | ## License 17 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwalkframe%2Freact-gridsheet.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwalkframe%2Freact-gridsheet?ref=badge_large) 18 | -------------------------------------------------------------------------------- /e2e/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | fullyParallel: true, 5 | use: { 6 | trace: 'on-first-retry', 7 | video: 'retain-on-failure', 8 | launchOptions: { 9 | slowMo: 250, 10 | }, 11 | permissions: ['clipboard-read'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /e2e/search.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('search and next', async ({ page }) => { 4 | await page.goto('http://localhost:5233/iframe.html?id=basic--large&viewMode=story'); 5 | const a1 = page.locator("[data-address='A1']"); 6 | await a1.click(); 7 | 8 | const searchBar = page.locator('.gs-search-bar'); 9 | const progress = page.locator('.gs-search-progress'); 10 | expect(await searchBar.count()).toBe(0); 11 | 12 | await page.keyboard.press('Control+f'); 13 | await page.waitForSelector('.gs-search-bar', { timeout: 3000 }); 14 | 15 | expect(await searchBar.count()).toBe(1); 16 | expect(await progress.textContent()).toBe('0 / 0'); 17 | 18 | await page.keyboard.up('Control'); 19 | await page.keyboard.type('aa'); 20 | expect(await progress.textContent()).toBe('1 / 3'); 21 | 22 | const a500 = page.locator("[data-address='A500']"); 23 | expect(await a500.getAttribute('class')).toContain('gs-choosing'); 24 | 25 | await page.keyboard.press('Enter'); 26 | expect(await progress.textContent()).toBe('2 / 3'); 27 | const a1000 = page.locator("[data-address='A1000']"); 28 | expect(await a1000.getAttribute('class')).toContain('gs-choosing'); 29 | 30 | await page.keyboard.press('Enter'); 31 | expect(await progress.textContent()).toBe('3 / 3'); 32 | const cv1000 = page.locator("[data-address='CV1000']"); 33 | expect(await cv1000.getAttribute('class')).toContain('gs-choosing'); 34 | 35 | // update the keyword to 'aaa' 36 | await page.keyboard.type('a'); 37 | expect(await progress.textContent()).toBe('1 / 2'); 38 | expect(await a1000.getAttribute('class')).toContain('gs-choosing'); 39 | 40 | await page.keyboard.press('Enter'); 41 | expect(await progress.textContent()).toBe('2 / 2'); 42 | expect(await cv1000.getAttribute('class')).toContain('gs-choosing'); 43 | 44 | await page.keyboard.press('Enter'); 45 | expect(await progress.textContent()).toBe('1 / 2'); 46 | expect(await a1000.getAttribute('class')).toContain('gs-choosing'); 47 | 48 | await page.keyboard.press('Escape'); 49 | expect(await searchBar.count()).toBe(0); 50 | }); 51 | -------------------------------------------------------------------------------- /e2e/time.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('time + delta, time + number(days)', async ({ page }) => { 4 | await page.goto('http://localhost:5233/iframe.html?id=basic--small&viewMode=story'); 5 | const a4 = page.locator("[data-address='A4']"); 6 | const b4 = page.locator("[data-address='B4']"); 7 | const c4 = page.locator("[data-address='C4']"); 8 | const a5 = page.locator("[data-address='A5']"); 9 | 10 | expect(await a4.locator('.gs-cell-rendered').textContent()).toBe('2022-03-05 12:34:56'); 11 | expect(await b4.locator('.gs-cell-rendered').textContent()).toBe('11:11:11'); 12 | expect(await c4.locator('.gs-cell-rendered').textContent()).toBe('2022-03-05 23:46:07'); 13 | expect(await a5.locator('.gs-cell-rendered').textContent()).toBe('2022-03-04 23:34:56'); 14 | }); 15 | 16 | test('input DD MMM [YYYY] format', async ({ page }) => { 17 | await page.goto('http://localhost:5233/iframe.html?id=basic--small&viewMode=story'); 18 | 19 | const b5 = page.locator("[data-address='B5']"); 20 | await b5.click(); 21 | await page.keyboard.type('30 Nov'); 22 | await page.keyboard.press('Enter'); 23 | expect(await b5.locator('.gs-cell-rendered').textContent()).toBe('2001-11-29 15:00:00'); 24 | 25 | const c5 = page.locator("[data-address='C5']"); 26 | await c5.click(); 27 | await page.keyboard.type('30 Nov 2024'); 28 | await page.keyboard.press('Enter'); 29 | expect(await c5.locator('.gs-cell-rendered').textContent()).toBe('2024-11-29 15:00:00'); 30 | }); 31 | -------------------------------------------------------------------------------- /e2e/utils.ts: -------------------------------------------------------------------------------- 1 | export const jsonMinify = (json: string) => { 2 | return JSON.stringify(JSON.parse(json)); 3 | }; 4 | 5 | export const jsonQuery = (json: string, keys: string[]) => { 6 | const obj = JSON.parse(json); 7 | return keys.reduce((acc, key) => { 8 | return acc[key]; 9 | }, obj); 10 | }; 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import tseslint from "@typescript-eslint/eslint-plugin"; 3 | import tseslintParser from "@typescript-eslint/parser"; 4 | import prettier from "eslint-config-prettier"; 5 | 6 | export default [ 7 | { 8 | ignores: [ 9 | '**/dist/**/*', 10 | '**/*.js', 11 | '**/storybook/**/*', 12 | 'e2e/**/*', 13 | '**/*.config.ts', 14 | '**/*.spec.ts' 15 | ], 16 | }, 17 | { 18 | languageOptions: { 19 | parser: tseslintParser, 20 | }, 21 | }, 22 | { 23 | plugins: { 24 | "@typescript-eslint": tseslint, 25 | prettier, 26 | }, 27 | languageOptions: { 28 | parserOptions: { 29 | sourceType: "module", 30 | project: "./tsconfig.json", 31 | }, 32 | }, 33 | rules: { 34 | semi: "error", 35 | "@typescript-eslint/no-explicit-any": "off", 36 | "@typescript-eslint/no-unsafe-assignment": "off", 37 | "@typescript-eslint/no-unsafe-member-access": "off", 38 | "@typescript-eslint/no-unsafe-return": "off", 39 | "@typescript-eslint/no-unused-vars": "off", 40 | }, 41 | }, 42 | ]; -------------------------------------------------------------------------------- /gridsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walkframe/gridsheet/8092970c4390039cbca3b6c8eb2f5c860118b7d1/gridsheet.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gridsheet/root", 3 | "type": "module", 4 | "private": true, 5 | "packageManager": "pnpm@10.6.5", 6 | "devDependencies": { 7 | "@eslint/js": "^9.22.0", 8 | "@playwright/test": "^1.51.1", 9 | "@typescript-eslint/eslint-plugin": "^8.27.0", 10 | "@typescript-eslint/parser": "^8.27.0", 11 | "eslint": "^9.22.0", 12 | "eslint-config-prettier": "^10.1.1", 13 | "eslint-plugin-node": "^11.1.0", 14 | "prettier": "^3.5.3", 15 | "typescript": "^5.8.2" 16 | }, 17 | "scripts": { 18 | "dev": "cd packages/storybook && pnpm dev", 19 | "build:core": "cd packages/react-core && pnpm build --mode development", 20 | "build:right-menu": "cd packages/react-right-menu && pnpm build --mode development", 21 | "build:storybook": "cd packages/storybook && pnpm build", 22 | "build:all": "pnpm build:core && pnpm build:right-menu && pnpm build:storybook", 23 | "typecheck:core": "cd packages/react-core && pnpm typecheck", 24 | "typecheck:right-menu": "cd packages/react-right-menu && pnpm typecheck", 25 | "typecheck:all": "pnpm typecheck:core && pnpm typecheck:right-menu", 26 | "e2e": "cd e2e && npx playwright test -c playwright.config.ts --workers 4", 27 | "jest:core": "cd packages/react-core && pnpm jest", 28 | "jest": "pnpm jest:core", 29 | "test": "pnpm jest && pnpm e2e", 30 | "prettier": "prettier --write '**/*.{ts,tsx}'", 31 | "eslint": "eslint --ext .ts,.tsx .", 32 | "eslint:fix": "pnpm eslint --fix && pnpm prettier", 33 | "remove:root": "rm -rf node_modules", 34 | "remove:core": "cd packages/react-core && rm -rf node_modules", 35 | "remove:right-menu": "cd packages/react-right-menu && rm -rf node_modules", 36 | "remove:storybook": "cd packages/storybook && rm -rf node_modules", 37 | "remove:all": "pnpm remove:core && pnpm remove:right-menu && pnpm remove:storybook", 38 | "install:root": "pnpm install", 39 | "install:core": "cd packages/react-core && pnpm install", 40 | "install:right-menu": "cd packages/react-right-menu && pnpm install", 41 | "install:storybook": "cd packages/storybook && pnpm install", 42 | "install:all": "pnpm install:core && pnpm install:right-menu && pnpm install:storybook", 43 | "reset:all": "pnpm remove:all && pnpm install:all" 44 | } 45 | } -------------------------------------------------------------------------------- /packages/react-core/README.md: -------------------------------------------------------------------------------- 1 | [![NPM](https://nodei.co/npm/@gridsheet/react-core.png?mini=true)](https://www.npmjs.com/package/@gridsheet/react-core) 2 | 3 | ![unittest workflow](https://github.com/walkframe/gridsheet/actions/workflows/unittest.yaml/badge.svg?branch=master) 4 | ![e2e workflow](https://github.com/walkframe/gridsheet/actions/workflows/e2e.yaml/badge.svg?branch=master) 5 | 6 | ## Introduction 7 | 8 | @gridsheet/react-core is a simple yet highly functional spreadsheet component for ReactJS. 9 | 10 | ![gridsheet](https://github.com/walkframe/gridsheet/raw/master/gridsheet.png) 11 | 12 | 13 | ## Installation 14 | 15 | ```sh 16 | $ npm install @gridsheet/react-core --save 17 | ``` 18 | 19 | ## Docs 20 | 21 | - [ReactGridsheet document](https://docs.walkframe.com/gridsheet/react) 22 | - [Examples](https://docs.walkframe.com/gridsheet/Examples/react-case1) 23 | - [Histories](https://docs.walkframe.com/gridsheet/history) 24 | 25 | ## License 26 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwalkframe%2Freact-gridsheet.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwalkframe%2Freact-gridsheet?ref=badge_large) 27 | 28 | -------------------------------------------------------------------------------- /packages/react-core/components/Emitter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Context } from '../store'; 4 | import { FeedbackType } from '../types'; 5 | 6 | type Props = { 7 | onChange?: FeedbackType; 8 | onSelect?: FeedbackType; 9 | }; 10 | 11 | export const Emitter: React.FC<Props> = ({ onChange, onSelect }) => { 12 | const { store } = React.useContext(Context); 13 | const { choosing: pointing, selectingZone: zone, table, tableInitialized } = store; 14 | 15 | React.useEffect(() => { 16 | tableInitialized && 17 | table && 18 | onChange && 19 | onChange(table, { 20 | pointing, 21 | selectingFrom: { y: zone.startY, x: zone.startX }, 22 | selectingTo: { y: zone.endY, x: zone.endX }, 23 | }); 24 | }, [table]); 25 | 26 | React.useEffect(() => { 27 | onSelect && 28 | onSelect(table, { 29 | pointing, 30 | selectingFrom: { y: zone.startY, x: zone.startX }, 31 | selectingTo: { y: zone.endY, x: zone.endX }, 32 | }); 33 | }, [pointing, zone]); 34 | return <></>; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/react-core/components/Fixed.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | import { useBrowser } from './hooks'; 4 | 5 | type Props = { 6 | className?: string; 7 | style?: CSSProperties; 8 | children: React.ReactNode; 9 | [attr: string]: any; 10 | }; 11 | 12 | export const Fixed: React.FC<Props> = ({ children, style, className = '', ...attrs }) => { 13 | const { document } = useBrowser(); 14 | if (document == null) { 15 | return null; 16 | } 17 | return createPortal( 18 | <div {...attrs} className={`gs-fixed ${className}`} style={style}> 19 | {children} 20 | </div>, 21 | document.body, 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/react-core/components/PluginBase.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { StoreType } from '../types'; 4 | import { Dispatcher } from '../store'; 5 | 6 | export type PluginContextType = { 7 | provided: boolean; 8 | store?: StoreType; 9 | dispatch?: Dispatcher; 10 | setStore: (store: StoreType) => void; 11 | setDispatch: (dispatch: Dispatcher) => void; 12 | }; 13 | 14 | export const PluginContext = React.createContext({} as PluginContextType); 15 | 16 | export function useInitialPluginContext(): PluginContextType { 17 | const [store, setStore] = React.useState<StoreType | undefined>(undefined); 18 | const [dispatch, setDispatch] = React.useState<Dispatcher>(); 19 | return { 20 | provided: true, 21 | store, 22 | dispatch, 23 | setStore, 24 | setDispatch, 25 | }; 26 | } 27 | 28 | export function usePluginContext(): [boolean, PluginContextType] { 29 | const ctx = React.useContext(PluginContext); 30 | if (ctx?.provided == null) { 31 | return [false, ctx]; 32 | } 33 | return [true, ctx]; 34 | } 35 | 36 | export function usePluginDispatch() { 37 | const dispatch = React.useContext(PluginContext); 38 | if (!dispatch) { 39 | return undefined; 40 | } 41 | return dispatch; 42 | } 43 | 44 | type Props = { 45 | children: React.ReactNode; 46 | context: PluginContextType; 47 | }; 48 | 49 | export function PluginBase({ children, context }: Props) { 50 | const [provided] = usePluginContext(); 51 | if (provided) { 52 | return <>{children}</>; 53 | } 54 | return <PluginContext.Provider value={context}>{children}</PluginContext.Provider>; 55 | } 56 | -------------------------------------------------------------------------------- /packages/react-core/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { a2p } from '../lib/converters'; 2 | import React from 'react'; 3 | 4 | import { Context } from '../store'; 5 | import { setSearchQuery, search, setSearchCaseSensitive } from '../store/actions'; 6 | import { smartScroll } from '../lib/virtualization'; 7 | import { SearchIcon } from './svg/SearchIcon'; 8 | import { CloseIcon } from './svg/CloseIcon'; 9 | 10 | export const SearchBar: React.FC = () => { 11 | const { store, dispatch } = React.useContext(Context); 12 | const { 13 | rootRef, 14 | editorRef, 15 | searchInputRef, 16 | tabularRef, 17 | searchQuery, 18 | searchCaseSensitive, 19 | matchingCellIndex, 20 | matchingCells, 21 | table, 22 | } = store; 23 | 24 | const matchingCell = matchingCells[matchingCellIndex]; 25 | React.useEffect(() => { 26 | if (!matchingCell) { 27 | return; 28 | } 29 | const point = a2p(matchingCell); 30 | if (typeof point === 'undefined') { 31 | return; 32 | } 33 | smartScroll(table, tabularRef.current, point); 34 | }, [searchQuery, matchingCellIndex, searchCaseSensitive]); 35 | 36 | if (typeof searchQuery === 'undefined') { 37 | return null; 38 | } 39 | if (rootRef.current === null) { 40 | return null; 41 | } 42 | return ( 43 | <label className={`gs-search-bar ${matchingCells.length > 0 ? 'gs-search-found' : ''}`}> 44 | <div 45 | className="gs-search-progress" 46 | onClick={(e) => { 47 | const input = e.currentTarget.previousSibling as HTMLInputElement; 48 | input?.nodeName === 'INPUT' && input.focus(); 49 | }} 50 | > 51 | {matchingCells.length === 0 ? 0 : matchingCellIndex + 1} / {matchingCells.length} 52 | </div> 53 | <div className="gs-search-bar-icon" onClick={() => dispatch(search(1))}> 54 | <SearchIcon style={{ verticalAlign: 'middle', marginLeft: '5px' }} /> 55 | </div> 56 | <textarea 57 | ref={searchInputRef} 58 | value={searchQuery} 59 | onChange={(e) => dispatch(setSearchQuery(e.target.value))} 60 | onKeyDown={(e) => { 61 | if (e.key === 'Escape') { 62 | const el = editorRef?.current; 63 | if (el) { 64 | el.focus(); 65 | } 66 | dispatch(setSearchQuery(undefined)); 67 | } 68 | if (e.key === 'f' && (e.ctrlKey || e.metaKey)) { 69 | e.preventDefault(); 70 | return false; 71 | } 72 | if (e.key === 'Enter') { 73 | dispatch(search(e.shiftKey ? -1 : 1)); 74 | e.preventDefault(); 75 | return false; 76 | } 77 | return true; 78 | }} 79 | ></textarea> 80 | <div className={`gs-search-casesensitive`}> 81 | <span 82 | className={`${searchCaseSensitive ? 'gs-search-casesensitive-on' : ''}`} 83 | onClick={() => dispatch(setSearchCaseSensitive(!searchCaseSensitive))} 84 | > 85 | Aa 86 | </span> 87 | </div> 88 | <a 89 | className="gs-search-close" 90 | onClick={() => { 91 | dispatch(setSearchQuery(undefined)); 92 | editorRef.current?.focus(); 93 | }} 94 | > 95 | <CloseIcon style={{ verticalAlign: 'middle' }} /> 96 | </a> 97 | </label> 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /packages/react-core/components/SheetProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { RefPaletteType, SheetMapType, TableMapType } from '../types'; 4 | 5 | export type SheetContextType = { 6 | mounted: boolean; 7 | sheets: React.MutableRefObject<SheetMapType>; 8 | tables: React.MutableRefObject<TableMapType>; 9 | head: React.MutableRefObject<number>; 10 | choosingCell: string; 11 | setChoosingCell: (cell: string) => void; 12 | editingCell: string; 13 | setEditingCell: (cell: string) => void; 14 | externalRefs?: { [sheetName: string]: RefPaletteType }; 15 | setExternalRefs?: (refs: { [sheetName: string]: RefPaletteType }) => void; 16 | lastFocusedRef: React.MutableRefObject<HTMLTextAreaElement | null>; 17 | setLastFocusedRef: (ref: React.MutableRefObject<HTMLTextAreaElement | null>) => void; 18 | forceRender: () => void; 19 | }; 20 | 21 | export const SheetContext = React.createContext({} as SheetContextType); 22 | 23 | export function useSheetContext(): [boolean, SheetContextType] { 24 | const ctx = React.useContext(SheetContext); 25 | if (ctx.tables?.current == null) { 26 | return [false, ctx]; 27 | } 28 | return [true, ctx]; 29 | } 30 | 31 | export function useSheetDispatch() { 32 | const dispatch = React.useContext(SheetContext); 33 | if (!dispatch) { 34 | return undefined; 35 | } 36 | return dispatch; 37 | } 38 | 39 | type Props = { 40 | children: React.ReactNode; 41 | }; 42 | 43 | export function SheetProvider({ children }: Props) { 44 | const [mounted, setMounted] = React.useState(false); 45 | const [version, setVersion] = React.useState(0); 46 | const head = React.useRef(1); 47 | const sheets = React.useRef<SheetMapType>({}); 48 | const tables = React.useRef<TableMapType>({}); 49 | const [choosingCell, setChoosingCell] = React.useState(''); 50 | const [editingCell, setEditingCell] = React.useState(''); 51 | const [externalRefs, setExternalRefs] = React.useState<{ [sheetName: string]: RefPaletteType }>({}); 52 | const lastFocusedRefInitial = React.useRef<HTMLTextAreaElement | null>(null); 53 | const [lastFocusedRef, setLastFocusedRef] = React.useState(lastFocusedRefInitial); 54 | 55 | React.useEffect(() => { 56 | setMounted(true); 57 | }, []); 58 | 59 | return ( 60 | <SheetContext.Provider 61 | value={{ 62 | mounted, 63 | tables, 64 | sheets, 65 | head, 66 | choosingCell, 67 | setChoosingCell, 68 | editingCell, 69 | setEditingCell, 70 | externalRefs, 71 | setExternalRefs, 72 | lastFocusedRef, 73 | setLastFocusedRef, 74 | forceRender: () => { 75 | if (version >= Number.MAX_SAFE_INTEGER) { 76 | setVersion(0); 77 | return; 78 | } 79 | setVersion(version + 1); 80 | }, 81 | }} 82 | > 83 | {children} 84 | </SheetContext.Provider> 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /packages/react-core/components/StoreInitializer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Props } from '../types'; 4 | 5 | import { Context } from '../store'; 6 | import { 7 | setSheetHeight, 8 | setSheetWidth, 9 | setHeaderHeight, 10 | setHeaderWidth, 11 | setEditingOnEnter, 12 | setShowAddress, 13 | setOnSave, 14 | initializeTable, 15 | setMode, 16 | } from '../store/actions'; 17 | 18 | import { HEADER_HEIGHT, HEADER_WIDTH } from '../constants'; 19 | import { useSheetContext } from './SheetProvider'; 20 | import { usePluginContext } from './PluginBase'; 21 | 22 | export const StoreInitializer: React.FC<Props> = ({ options = {} }) => { 23 | const { 24 | headerHeight = HEADER_HEIGHT, 25 | headerWidth = HEADER_WIDTH, 26 | sheetHeight, 27 | sheetWidth, 28 | editingOnEnter, 29 | showAddress, 30 | mode, 31 | onSave, 32 | } = options; 33 | 34 | const [sheetProvided, sheetContext] = useSheetContext(); 35 | const { store, dispatch } = React.useContext(Context); 36 | 37 | React.useEffect(() => { 38 | const { table, tableInitialized } = store; 39 | if (table == null || tableInitialized) { 40 | return; 41 | } 42 | 43 | if (!sheetProvided || sheetContext.mounted) { 44 | table.absolutizeFormula(); 45 | dispatch(initializeTable(table.shallowCopy())); 46 | } 47 | }, [sheetContext.mounted]); 48 | 49 | React.useEffect(() => { 50 | if (sheetHeight) { 51 | dispatch(setSheetHeight(sheetHeight)); 52 | } 53 | }, [sheetHeight]); 54 | React.useEffect(() => { 55 | if (sheetWidth) { 56 | dispatch(setSheetWidth(sheetWidth)); 57 | } 58 | }, [sheetWidth]); 59 | React.useEffect(() => { 60 | if (headerHeight) { 61 | dispatch(setHeaderHeight(headerHeight)); 62 | } 63 | }, [headerHeight]); 64 | React.useEffect(() => { 65 | if (headerWidth) { 66 | dispatch(setHeaderWidth(headerWidth)); 67 | } 68 | }, [headerWidth]); 69 | React.useEffect(() => { 70 | if (typeof editingOnEnter !== 'undefined') { 71 | dispatch(setEditingOnEnter(editingOnEnter)); 72 | } 73 | }, [editingOnEnter]); 74 | React.useEffect(() => { 75 | if (typeof showAddress !== 'undefined') { 76 | dispatch(setShowAddress(showAddress)); 77 | } 78 | }, [showAddress]); 79 | 80 | React.useEffect(() => { 81 | if (mode) { 82 | dispatch(setMode(mode)); 83 | } 84 | }, [mode]); 85 | 86 | React.useEffect(() => { 87 | if (typeof onSave !== 'undefined') { 88 | dispatch(setOnSave(onSave)); 89 | } 90 | }, [onSave]); 91 | 92 | const [pluginProvided, pluginContext] = usePluginContext(); 93 | React.useEffect(() => { 94 | if (!pluginProvided) { 95 | return; 96 | } 97 | pluginContext.setStore(store); 98 | pluginContext.setDispatch(() => dispatch); 99 | }, [store, dispatch]); 100 | 101 | return <></>; 102 | }; 103 | -------------------------------------------------------------------------------- /packages/react-core/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | defaultChecked?: boolean; 5 | width?: number; 6 | on: string; 7 | off: string; 8 | coloring?: boolean; 9 | onChange: (on: boolean) => void; 10 | }; 11 | 12 | export const Toggle: React.FC<Props> = ({ defaultChecked = false, width = 45, on, off, coloring = true, onChange }) => { 13 | return ( 14 | <label 15 | className={`gs-ui-toggle ${coloring ? 'gs-ui-toggle-colored' : ''}`} 16 | style={{ 17 | width, 18 | }} 19 | > 20 | <input 21 | defaultChecked={defaultChecked} 22 | type="checkbox" 23 | data-text-on={on} 24 | data-text-off={off} 25 | onChange={(e) => onChange(e.target.checked)} 26 | /> 27 | </label> 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/react-core/components/hooks.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Return the document object with SSR. 4 | export const useBrowser = () => { 5 | const [ok, setOk] = React.useState(false); 6 | React.useEffect(() => { 7 | setOk(true); 8 | }); 9 | if (ok && typeof window !== 'undefined') { 10 | return { window, document }; 11 | } 12 | return { window: null, document: null }; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/react-core/components/svg/AddIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { type IconProps, Base } from './Base'; 3 | 4 | // https://tabler.io/icons 5 | 6 | export const AddIcon = ({ style, color = 'none', size = 24 }: IconProps) => { 7 | return ( 8 | <Base style={style} size={size}> 9 | <path stroke="none" d="M0 0h24v24H0z" fill={color} /> 10 | <path d="M12 5l0 14" fill={color} /> 11 | <path d="M5 12l14 0" fill={color} /> 12 | </Base> 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/react-core/components/svg/Base.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export interface IconProps { 4 | style?: React.CSSProperties; 5 | color?: string; 6 | size?: number; 7 | } 8 | 9 | interface BaseProps extends IconProps { 10 | children?: React.ReactNode; 11 | } 12 | 13 | // https://tabler.io/icons 14 | 15 | export const Base = ({ style, size = 24, children }: BaseProps) => { 16 | return ( 17 | <svg 18 | xmlns="http://www.w3.org/2000/svg" 19 | width={size} 20 | height={size} 21 | viewBox={`0 0 24 24`} 22 | fill="none" 23 | stroke="currentColor" 24 | stroke-width="2" 25 | stroke-linecap="round" 26 | stroke-linejoin="round" 27 | style={style} 28 | className="icon-tabler" 29 | > 30 | {children} 31 | </svg> 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/react-core/components/svg/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { type IconProps, Base } from './Base'; 3 | 4 | // https://tabler.io/icons 5 | 6 | export const CloseIcon = ({ style, color = 'none', size = 24 }: IconProps) => { 7 | return ( 8 | <Base style={style} size={size}> 9 | <path stroke="none" d="M0 0h24v24H0z" fill={color} /> 10 | <path d="M18 6l-12 12" fill={color} /> 11 | <path d="M6 6l12 12" fill={color} /> 12 | </Base> 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/react-core/components/svg/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { type IconProps, Base } from './Base'; 3 | 4 | // https://tabler.io/icons 5 | 6 | export const SearchIcon = ({ style, color = 'none', size = 24 }: IconProps) => { 7 | return ( 8 | <Base style={style} size={size}> 9 | <path stroke="none" d="M0 0h24v24H0z" fill={color} /> 10 | <path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" fill={color} /> 11 | <path d="M21 21l-6 -6" fill={color} /> 12 | </Base> 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /packages/react-core/constants.ts: -------------------------------------------------------------------------------- 1 | const IMG_BASE64 = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; 2 | 3 | export const DUMMY_IMG = (typeof window === 'undefined' ? null : document.createElement('img')) as HTMLImageElement; 4 | 5 | if (DUMMY_IMG) { 6 | DUMMY_IMG.src = IMG_BASE64; 7 | } 8 | 9 | export const HISTORY_LIMIT = 10; 10 | 11 | export const DEFAULT_HEIGHT = 24; 12 | export const DEFAULT_WIDTH = 90; 13 | 14 | export const SHEET_HEIGHT = 500; 15 | export const SHEET_WIDTH = 1000; 16 | 17 | export const HEADER_HEIGHT = 24; 18 | export const HEADER_WIDTH = 50; 19 | 20 | export const MIN_WIDTH = 5; 21 | export const MIN_HEIGHT = 5; 22 | 23 | export const OVERSCAN_X = 5; 24 | export const OVERSCAN_Y = 10; 25 | 26 | export const DEFAULT_ALPHABET_CACHE_SIZE = 1000; 27 | 28 | export class Special { 29 | public name: string; 30 | constructor(name: string) { 31 | this.name = name; 32 | } 33 | } 34 | 35 | export const SECONDS_IN_DAY = 86400; 36 | export const FULLDATE_FORMAT_UTC = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; 37 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/__base.ts: -------------------------------------------------------------------------------- 1 | import { Table } from '../../lib/table'; 2 | import type { PointType } from '../../types'; 3 | import { Expression } from '../evaluator'; 4 | 5 | export type FunctionProps = { 6 | args: Expression[]; 7 | table: Table; 8 | origin?: PointType; 9 | }; 10 | 11 | export class BaseFunction { 12 | public example = '_BASE()'; 13 | public helpTexts = ["Function's description."]; 14 | public helpArgs = [{ name: 'value1', description: '' }]; 15 | protected bareArgs: any[]; 16 | protected table: Table; 17 | protected origin?: PointType; 18 | 19 | constructor({ args, table, origin }: FunctionProps) { 20 | this.bareArgs = args.map((a) => a.evaluate({ table })); 21 | this.table = table; 22 | this.origin = origin; 23 | } 24 | protected validate() {} 25 | 26 | public call() { 27 | this.validate(); 28 | 29 | // @ts-expect-error main is not defined in BaseFunction 30 | 31 | return this.main(...this.bareArgs); 32 | } 33 | } 34 | 35 | export type FunctionMapping = { [functionName: string]: typeof BaseFunction }; 36 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/abs.spec.ts: -------------------------------------------------------------------------------- 1 | import { AbsFunction } from './abs'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('abs', () => { 6 | const table = new Table({}); 7 | table.initialize({ B2: { value: -222 } }); 8 | describe('normal', () => { 9 | it('negative to positive', () => { 10 | const f = new AbsFunction({ table, args: [new ValueEntity(-111)] }); 11 | expect(f.call()).toBe(111); 12 | }); 13 | it('refers to a cell', () => { 14 | const f = new AbsFunction({ table, args: [new RefEntity('B2')] }); 15 | expect(f.call()).toBe(222); 16 | }); 17 | it('positive to positive', () => { 18 | const f = new AbsFunction({ table, args: [new ValueEntity(333)] }); 19 | expect(f.call()).toBe(333); 20 | }); 21 | }); 22 | describe('validation error', () => { 23 | it('missing argument', () => { 24 | const f = new AbsFunction({ table, args: [] }); 25 | expect(f.call.bind(f)).toThrow(FormulaError); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/abs.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class AbsFunction extends BaseFunction { 6 | example = 'ABS(-2)'; 7 | helpText = ['Returns the absolute value of a number']; 8 | helpArgs = [{ name: 'value', description: 'target number' }]; 9 | 10 | protected validate() { 11 | if (this.bareArgs.length !== 1) { 12 | throw new FormulaError('#N/A', 'Number of arguments for ABS is incorrect.'); 13 | } 14 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 15 | } 16 | 17 | protected main(value: number) { 18 | return Math.abs(value); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/acos.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class AcosFunction extends BaseFunction { 6 | example = 'ACOS(0)'; 7 | helpText = ['Returns the inverse cos of the value in radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A value for the inverse cos between -1 and 1.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for ACOS is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | if (-1 > this.bareArgs[0] || this.bareArgs[0] > 1) { 21 | throw new FormulaError('#NUM!', 'value must be between -1 and 1'); 22 | } 23 | } 24 | 25 | protected main(value: number) { 26 | return Math.acos(value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/add.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import { FormulaError } from '../evaluator'; 4 | import { BaseFunction } from './__base'; 5 | import { ensureNumber, stripTable } from './__utils'; 6 | import { Table } from '../../lib/table'; 7 | import { TimeDelta } from '../../lib/time'; 8 | import { SECONDS_IN_DAY } from '../../constants'; 9 | 10 | export class AddFunction extends BaseFunction { 11 | example = 'ADD(2, 3)'; 12 | helpText = ['Returns the sum of two numbers.', "This is the same as the '+' operator."]; 13 | helpArgs = [ 14 | { name: 'value1', description: 'First additive.' }, 15 | { name: 'value2', description: 'Second additive.' }, 16 | ]; 17 | 18 | protected validate() { 19 | if (this.bareArgs.length !== 2) { 20 | throw new FormulaError('#N/A', 'Number of arguments for ADD is incorrect.'); 21 | } 22 | this.bareArgs = this.bareArgs.map((arg) => { 23 | if (arg instanceof Table) { 24 | arg = stripTable(arg, 0, 0); 25 | } 26 | return typeof arg === 'object' ? arg : ensureNumber(arg); 27 | }); 28 | } 29 | 30 | protected main(v1: number | Date | TimeDelta, v2: number | Date | TimeDelta) { 31 | if (typeof v1 === 'number' && typeof v2 === 'number') { 32 | return v1 + v2; 33 | } 34 | if (v1 instanceof Date && TimeDelta.is(v2)) { 35 | return TimeDelta.ensure(v2).add(v1); 36 | } 37 | if (TimeDelta.is(v1) && v2 instanceof Date) { 38 | return TimeDelta.ensure(v1).add(v2); 39 | } 40 | if (v1 instanceof Date && typeof v2 === 'number') { 41 | return dayjs(v1) 42 | .add(v2 * SECONDS_IN_DAY, 'second') 43 | .toDate(); 44 | } 45 | if (typeof v1 === 'number' && v2 instanceof Date) { 46 | return dayjs(v2) 47 | .add(v1 * SECONDS_IN_DAY, 'second') 48 | .toDate(); 49 | } 50 | if (!v1) { 51 | return v2; 52 | } 53 | if (!v2) { 54 | return v1; 55 | } 56 | throw new FormulaError('#VALUE!', 'Mismatched types for augend and addend.'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/and.ts: -------------------------------------------------------------------------------- 1 | import { BaseFunction } from './__base'; 2 | import { ensureBoolean } from './__utils'; 3 | 4 | export class AndFunction extends BaseFunction { 5 | example = 'AND(A1=1, A2=2)'; 6 | helpText = ['Returns TRUE if all arguments are logically TRUE.', 'Returns FALSE if any argument is logically FALSE.']; 7 | helpArgs = [ 8 | { name: 'expression1', description: 'First logical expression.' }, 9 | { 10 | name: 'expression2', 11 | description: 'Additional expressions', 12 | optional: true, 13 | iterable: true, 14 | }, 15 | ]; 16 | 17 | protected validate() { 18 | this.bareArgs = this.bareArgs.map((arg) => ensureBoolean(arg)); 19 | } 20 | 21 | protected main(...values: boolean[]) { 22 | return values.reduce((a, b) => a && b); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/asin.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class AsinFunction extends BaseFunction { 6 | example = 'ASIN(0)'; 7 | helpText = ['Returns the inverse sin of the value in radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A value for the inverse sin between -1 and 1.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for ASIN is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | if (-1 > this.bareArgs[0] || this.bareArgs[0] > 1) { 21 | throw new FormulaError('#NUM!', 'value must be between -1 and 1'); 22 | } 23 | } 24 | 25 | protected main(value: number) { 26 | return Math.asin(value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/atan.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class AtanFunction extends BaseFunction { 6 | example = 'ATAN(1)'; 7 | helpText = ['Returns the inverse tan of the value in radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A value for the inverse tan.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for ATAN is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | } 21 | 22 | protected main(value: number) { 23 | return Math.atan(value); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/atan2.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class Atan2Function extends BaseFunction { 6 | example = 'ATAN2(4,3)'; 7 | helpText = [ 8 | 'Returns the angle in radians between the x-axis and a line passing from the origin through a given coordinate point (x, y).', 9 | ]; 10 | helpArgs = [ 11 | { 12 | name: 'x', 13 | description: 'x of the point.', 14 | }, 15 | { 16 | name: 'y', 17 | description: 'y of the point.', 18 | }, 19 | ]; 20 | 21 | protected validate() { 22 | if (this.bareArgs.length !== 2) { 23 | throw new FormulaError('#N/A', 'Number of arguments for ATAN2 is incorrect.'); 24 | } 25 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 26 | } 27 | 28 | protected main(x: number, y: number) { 29 | return Math.atan2(x, y); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/average.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { ensureNumber } from './__utils'; 6 | 7 | export class AverageFunction extends BaseFunction { 8 | example = 'AVERAGE(A2:A100, 101)'; 9 | helpText = ['Returns the average of a series of numbers or cells.']; 10 | helpArgs = [ 11 | { name: 'value1', description: 'First number or range.' }, 12 | { 13 | name: 'value2', 14 | description: 'Additional numbers or ranges', 15 | optional: true, 16 | iterable: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | const spreaded: number[] = []; 22 | this.bareArgs.map((arg) => { 23 | if (arg instanceof Table) { 24 | spreaded.push( 25 | ...solveTable({ table: arg }) 26 | .reduce((a, b) => a.concat(b)) 27 | .filter((v: any) => typeof v === 'number'), 28 | ); 29 | return; 30 | } 31 | spreaded.push(ensureNumber(arg)); 32 | }); 33 | if (spreaded.length === 0) { 34 | throw new FormulaError('#N/A', 'Number of arguments must be greater than 0.'); 35 | } 36 | this.bareArgs = spreaded; 37 | } 38 | 39 | protected main(...values: number[]) { 40 | return values.reduce((a, b) => a + b) / values.length; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/col.spec.ts: -------------------------------------------------------------------------------- 1 | import { ColFunction } from './col'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, RangeEntity, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('col', () => { 6 | const table = new Table({}); 7 | table.initialize({ 8 | A4: { value: 9999 }, 9 | }); 10 | describe('normal', () => { 11 | it('eq', () => { 12 | { 13 | const f = new ColFunction({ 14 | table, 15 | args: [new RefEntity('C100')], 16 | }); 17 | expect(f.call()).toBe(3); 18 | } 19 | { 20 | const f = new ColFunction({ 21 | table, 22 | args: [], 23 | origin: { x: 5, y: 3 }, 24 | }); 25 | expect(f.call()).toBe(5); 26 | } 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/col.ts: -------------------------------------------------------------------------------- 1 | import { Table } from '../../lib/table'; 2 | import { FormulaError } from '../evaluator'; 3 | import { BaseFunction } from './__base'; 4 | 5 | export class ColFunction extends BaseFunction { 6 | example = 'COL(A9)'; 7 | helpText = ['Returns the col number of a specified cell.']; 8 | helpArgs = [ 9 | { 10 | name: 'cell_reference', 11 | description: 'The cell whose col number will be returned.', 12 | option: true, 13 | }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length === 0) { 18 | this.bareArgs = [this.origin?.x ?? 1]; 19 | } else if (this.bareArgs.length === 1) { 20 | const table = this.bareArgs[0] as Table; 21 | this.bareArgs = [table.left]; 22 | } else { 23 | throw new FormulaError('#N/A', 'Number of arguments for COL is incorrect.'); 24 | } 25 | } 26 | 27 | protected main(left: number) { 28 | return left; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/concat.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureString } from './__utils'; 4 | 5 | export class ConcatFunction extends BaseFunction { 6 | example = 'CONCAT("Hello", "World")'; 7 | helpText = ['Returns the concatenation of two values.', "This is the same as the '&' operator."]; 8 | helpArgs = [ 9 | { name: 'value1', description: 'A value to be concatenated with value2.' }, 10 | { name: 'value2', description: 'A value to be concatenated with value1' }, 11 | ]; 12 | 13 | protected validate() { 14 | if (this.bareArgs.length !== 2) { 15 | throw new FormulaError('#N/A', 'Number of arguments for CONCAT is incorrect.'); 16 | } 17 | this.bareArgs = this.bareArgs.map((arg) => ensureString(arg)); 18 | } 19 | 20 | protected main(v1: string, v2: string) { 21 | return v1 + v2; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/concatenate.ts: -------------------------------------------------------------------------------- 1 | import { BaseFunction } from './__base'; 2 | import { ensureString } from './__utils'; 3 | 4 | export class ConcatenateFunction extends BaseFunction { 5 | example = 'CONCATENATE("Hello", "World")'; 6 | helpText = ['Returns the concatenation of the values.']; 7 | helpArgs = [ 8 | { name: 'value1', description: 'First string value.' }, 9 | { 10 | name: 'value2', 11 | description: 'Additional string values to be concatenated with the value1', 12 | optional: true, 13 | iterable: true, 14 | }, 15 | ]; 16 | 17 | protected validate() { 18 | this.bareArgs = this.bareArgs.map((arg) => ensureString(arg)); 19 | } 20 | 21 | protected main(...values: string[]) { 22 | return values.reduce((a, b) => a + b); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/cos.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class CosFunction extends BaseFunction { 6 | example = 'COS(PI()/2)'; 7 | helpText = ['Returns the cos of the angle specified in radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'angle', 11 | description: 'An angle in radians, at which you want the cos.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for COS is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | } 21 | 22 | protected main(angle: number) { 23 | return Math.cos(angle); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/count.ts: -------------------------------------------------------------------------------- 1 | import { solveTable } from '../solver'; 2 | import { Table } from '../../lib/table'; 3 | import { BaseFunction } from './__base'; 4 | import { ensureNumber } from './__utils'; 5 | 6 | export class CountFunction extends BaseFunction { 7 | example = 'COUNT(A2:A100,B2:B100,4,26)'; 8 | helpText = ['Returns the count of a series of numbers or cells.']; 9 | helpArgs = [ 10 | { name: 'value1', description: 'First number or range.' }, 11 | { 12 | name: 'value2', 13 | description: 'Additional numbers or ranges', 14 | optional: true, 15 | iterable: true, 16 | }, 17 | ]; 18 | 19 | protected validate() { 20 | const spreaded: any[] = []; 21 | this.bareArgs.map((arg) => { 22 | if (arg instanceof Table) { 23 | spreaded.push(...solveTable({ table: arg }).reduce((a, b) => a.concat(b))); 24 | return; 25 | } 26 | spreaded.push(ensureNumber(arg)); 27 | }); 28 | this.bareArgs = spreaded; 29 | } 30 | 31 | protected main(...values: any[]) { 32 | return values.filter((v) => typeof v === 'number').length; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/counta.ts: -------------------------------------------------------------------------------- 1 | import { solveTable } from '../solver'; 2 | import { Table } from '../../lib/table'; 3 | import { BaseFunction } from './__base'; 4 | import { ensureNumber } from './__utils'; 5 | 6 | export class CountaFunction extends BaseFunction { 7 | example = 'COUNTA(A2:A100,B2:B100,4,26)'; 8 | helpText = ['Returns the number of values in the data set.']; 9 | helpArgs = [ 10 | { name: 'value1', description: 'First number or range.' }, 11 | { 12 | name: 'value2', 13 | description: 'Additional numbers or ranges', 14 | optional: true, 15 | iterable: true, 16 | }, 17 | ]; 18 | 19 | protected validate() { 20 | const spreaded: any[] = []; 21 | this.bareArgs.map((arg) => { 22 | if (arg instanceof Table) { 23 | spreaded.push(...solveTable({ table: arg }).reduce((a, b) => a.concat(b))); 24 | return; 25 | } 26 | spreaded.push(ensureNumber(arg)); 27 | }); 28 | this.bareArgs = spreaded; 29 | } 30 | 31 | protected main(...values: any[]) { 32 | return values.filter((v) => v != null && v !== '').length; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/countif.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { check, ensureString } from './__utils'; 6 | 7 | export class CountifFunction extends BaseFunction { 8 | example = 'COUNTIF(A1:A10,">20")'; 9 | helpText = ['Returns the count of a series of cells.']; 10 | helpArgs = [ 11 | { name: 'range', description: 'Target range.' }, 12 | { 13 | name: 'condition', 14 | description: 'A condition for count.', 15 | }, 16 | ]; 17 | 18 | protected validate() { 19 | if (this.bareArgs.length !== 2) { 20 | throw new FormulaError('#N/A', 'Number of arguments for COUNTIF is incorrect.'); 21 | } 22 | this.bareArgs[1] = ensureString(this.bareArgs[1]); 23 | } 24 | 25 | protected main(table: Table, condition: string) { 26 | const matrix = solveTable({ table }); 27 | return matrix.reduce((a, b) => a.concat(b)).filter((v: any) => check(v, condition)).length; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/divide.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class DivideFunction extends BaseFunction { 6 | example = 'DIVIDE(4, 2)'; 7 | helpText = ['Returns the result of dividing one number by another.', "This is the same as the '/' operator."]; 8 | helpArgs = [ 9 | { 10 | name: 'dividend', 11 | description: 'A number that will be divided by divisor.', 12 | }, 13 | { name: 'divisor', description: 'A number that will divide a dividend.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for DIVIDE is incorrect.'); 19 | } 20 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 21 | if (this.bareArgs[1] === 0) { 22 | throw new FormulaError('#DIV/0!', 'The second argument must be non-zero.'); 23 | } 24 | } 25 | 26 | protected main(divided: number, divisor: number) { 27 | return divided / divisor; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/eq.spec.ts: -------------------------------------------------------------------------------- 1 | import { EqFunction } from './eq'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('eq', () => { 6 | const table = new Table({}); 7 | table.initialize({ 8 | A1: { value: 101 }, 9 | A2: { value: 101 }, 10 | A3: { value: 103 }, 11 | B1: { value: 'abc' }, 12 | B2: { value: 'abcd' }, 13 | C1: { value: new Date('2022-05-23T12:34:56+09:00') }, 14 | C2: { value: new Date('2022-05-23T12:34:56.999+09:00') }, 15 | C3: { value: new Date('2022-05-23T12:34:56Z') }, 16 | D4: { value: '=1/0' }, 17 | E5: { value: null }, 18 | }); 19 | 20 | describe('normal', () => { 21 | it('A1=101 is true', () => { 22 | const f = new EqFunction({ 23 | table, 24 | args: [new RefEntity('A1'), new ValueEntity(101)], 25 | }); 26 | expect(f.call()).toBe(true); 27 | }); 28 | it('A1=A2 is true', () => { 29 | const f = new EqFunction({ 30 | table, 31 | args: [new RefEntity('A1'), new RefEntity('A2')], 32 | }); 33 | expect(f.call()).toBe(true); 34 | }); 35 | it('A1=A3 is false', () => { 36 | const f = new EqFunction({ 37 | table, 38 | args: [new RefEntity('A1'), new RefEntity('A3')], 39 | }); 40 | expect(f.call()).toBe(false); 41 | }); 42 | it('B1=abc is true', () => { 43 | const f = new EqFunction({ 44 | table, 45 | args: [new RefEntity('B1'), new ValueEntity('abc')], 46 | }); 47 | expect(f.call()).toBe(true); 48 | }); 49 | it('B1=B2 is false', () => { 50 | const f = new EqFunction({ 51 | table, 52 | args: [new RefEntity('B1'), new RefEntity('B2')], 53 | }); 54 | expect(f.call()).toBe(false); 55 | }); 56 | it('C1=raw date is true', () => { 57 | const f = new EqFunction({ 58 | table, 59 | args: [new RefEntity('C1'), new ValueEntity(new Date('2022-05-23T12:34:56+09:00'))], 60 | }); 61 | expect(f.call()).toBe(true); 62 | }); 63 | it('C1=C2 is false', () => { 64 | const f = new EqFunction({ 65 | table, 66 | args: [new RefEntity('C1'), new RefEntity('C2')], 67 | }); 68 | expect(f.call()).toBe(false); 69 | }); 70 | it('C1=C3 is false', () => { 71 | const f = new EqFunction({ 72 | table, 73 | args: [new RefEntity('C1'), new RefEntity('C3')], 74 | }); 75 | expect(f.call()).toBe(false); 76 | }); 77 | it('null is blank', () => { 78 | const f = new EqFunction({ 79 | table, 80 | args: [new ValueEntity(null), new ValueEntity('')], 81 | }); 82 | expect(f.call()).toBe(true); 83 | }); 84 | }); 85 | describe('validation error', () => { 86 | it('missing argument', () => { 87 | const f = new EqFunction({ table, args: [] }); 88 | expect(f.call.bind(f)).toThrow(FormulaError); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/eq.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { eq } from './__utils'; 4 | 5 | export class EqFunction extends BaseFunction { 6 | example = 'EQ(6, 7)'; 7 | helpText = [ 8 | 'Returns TRUE if the two specified values are equal, FALSE if they are not.', 9 | "This is the same as the '=' operator.", 10 | ]; 11 | helpArgs = [ 12 | { name: 'value1', description: 'First value.' }, 13 | { name: 'value2', description: 'A value to be compared with value1.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for EQ is incorrect.'); 19 | } 20 | } 21 | 22 | protected main(v1: any, v2: any) { 23 | return eq(v1, v2); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/exp.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class ExpFunction extends BaseFunction { 6 | example = 'EXP(2)'; 7 | helpText = ['Returns the power of a number whose base is the Euler number e.']; 8 | helpArgs = [ 9 | { 10 | name: 'exponent', 11 | description: 'It is an exponent of power with e as the base.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for EXP is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | } 21 | 22 | protected main(exponent: number) { 23 | return Math.exp(exponent); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/gt.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { gt } from './__utils'; 4 | 5 | export class GtFunction extends BaseFunction { 6 | example = 'GT(5, 3)'; 7 | helpText = [ 8 | 'Returns TRUE if the first argument is truly greater than the second, FALSE otherwise.', 9 | "This is the same as the '>' operator.", 10 | ]; 11 | helpArgs = [ 12 | { name: 'value1', description: 'First value.' }, 13 | { name: 'value2', description: 'A value to be compared with value1.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for GT is incorrect.'); 19 | } 20 | } 21 | 22 | protected main(v1: number, v2: number) { 23 | return gt(v1, v2); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/gte.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { gte } from './__utils'; 4 | 5 | export class GteFunction extends BaseFunction { 6 | example = 'GTE(5, 3)'; 7 | helpText = [ 8 | 'Returns TRUE if the first argument is greater than the second, FALSE otherwise.', 9 | "This is the same as the '>=' operator.", 10 | ]; 11 | helpArgs = [ 12 | { name: 'value1', description: 'First value.' }, 13 | { name: 'value2', description: 'A value to be compared with value1.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for GTE is incorrect.'); 19 | } 20 | } 21 | 22 | protected main(v1: number, v2: number) { 23 | return gte(v1, v2); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/hlookup.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { ensureBoolean, ensureNumber, stripTable } from './__utils'; 6 | 7 | export class HlookupFunction extends BaseFunction { 8 | example = 'HLOOKUP(10003, A2:Z6, 2, FALSE)'; 9 | helpText = [ 10 | 'Searches horizontally for the specified key in the first row of the range and returns the value of the specified cell in the same column.', 11 | ]; 12 | helpArgs = [ 13 | { name: 'key', description: 'Search key.' }, 14 | { 15 | name: 'range', 16 | description: 'A range for search', 17 | }, 18 | { 19 | name: 'index', 20 | description: 'The index of the row in the range.', 21 | }, 22 | { 23 | name: 'is_sorted', 24 | description: 25 | 'FALSE: Exact match. This is recommended. TRUE: Approximate match. Before you use an approximate match, sort your search key in ascending order. Otherwise, you may likely get a wrong return value.', 26 | option: true, 27 | }, 28 | ]; 29 | 30 | protected validate() { 31 | if (this.bareArgs.length !== 3 && this.bareArgs.length !== 4) { 32 | throw new FormulaError('#N/A', 'Number of arguments for HLOOKUP is incorrect.'); 33 | } 34 | if (this.bareArgs[0] instanceof Table) { 35 | this.bareArgs[0] = stripTable(this.bareArgs[0]); 36 | } 37 | if (!(this.bareArgs[1] instanceof Table)) { 38 | throw new FormulaError('#REF!', '2nd argument must be range'); 39 | } 40 | this.bareArgs[2] = ensureNumber(this.bareArgs[2]); 41 | this.bareArgs[3] = ensureBoolean(this.bareArgs[3], true); 42 | } 43 | 44 | protected main(key: any, range: Table, index: number, isSorted: boolean) { 45 | const matrix = solveTable({ table: range }); 46 | if (isSorted) { 47 | let last = -1; 48 | for (let x = 0; x <= range.getNumCols(); x++) { 49 | const v = matrix[0]?.[x]; 50 | if (v == null) { 51 | continue; 52 | } 53 | if (v <= key) { 54 | last = x; 55 | } else { 56 | break; 57 | } 58 | } 59 | if (last !== -1) { 60 | return matrix[index - 1]?.[last]; 61 | } 62 | } else { 63 | for (let x = 0; x <= range.getNumCols(); x++) { 64 | if (matrix[0]?.[x] === key) { 65 | return matrix[index - 1]?.[x]; 66 | } 67 | } 68 | } 69 | throw new FormulaError('#N/A', `No values found for '${key}'.`); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/if.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureBoolean } from './__utils'; 4 | 5 | export class IfFunction extends BaseFunction { 6 | example = 'IF(A2 = "Human", "Hello", "World")'; 7 | helpText = [ 8 | 'If the logical expression is TRUE, the second argument is returned.', 9 | 'If FALSE, the third argument is returned.', 10 | ]; 11 | helpArgs = [ 12 | { name: 'condition', description: 'An expression as a condition' }, 13 | { 14 | name: 'value1', 15 | description: 'value to be returned if the condition is true.', 16 | }, 17 | { 18 | name: 'value2', 19 | description: 'value to be returned if the condition is false.', 20 | optional: true, 21 | }, 22 | ]; 23 | 24 | protected validate() { 25 | if (this.bareArgs.length === 2 || this.bareArgs.length === 3) { 26 | this.bareArgs[0] = ensureBoolean(this.bareArgs[0]); 27 | return; 28 | } 29 | throw new FormulaError('#N/A', 'Number of arguments for IF is incorrect. 2 or 3 arguments must be specified.'); 30 | } 31 | 32 | protected main(condition: boolean, v1: any, v2: any = false) { 33 | return condition ? v1 : v2; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/iferror.spec.ts: -------------------------------------------------------------------------------- 1 | import { IfErrorFunction } from './iferror'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, FunctionEntity, RangeEntity, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('iferror', () => { 6 | const table = new Table({}); 7 | table.initialize({ 8 | A1: { value: '=100/5' }, 9 | B2: { value: '=100/0' }, 10 | C3: { value: '=C3' }, 11 | D4: { value: '=A2:E10' }, 12 | E5: { value: '=aaaaa' }, 13 | }); 14 | 15 | describe('normal', () => { 16 | it('no errors', () => { 17 | const f = new IfErrorFunction({ 18 | table, 19 | args: [new RefEntity('A1'), new ValueEntity('div 0')], 20 | }); 21 | expect(f.call()).toBe(20); 22 | }); 23 | it('div/0 error', () => { 24 | const f = new IfErrorFunction({ 25 | table, 26 | args: [new RefEntity('B2'), new ValueEntity('div 0')], 27 | }); 28 | expect(f.call()).toBe('div 0'); 29 | }); 30 | it('reference error', () => { 31 | const f = new IfErrorFunction({ 32 | table, 33 | args: [new RangeEntity('C3'), new RefEntity('A1')], 34 | }); 35 | expect(f.call()).toBe(20); 36 | }); 37 | it('range error', () => { 38 | const f = new IfErrorFunction({ 39 | table, 40 | args: [new RangeEntity('A2:E20')], 41 | }); 42 | expect(f.call()).toBe(undefined); 43 | }); 44 | it('name error', () => { 45 | const f = new IfErrorFunction({ 46 | table, 47 | args: [ 48 | new FunctionEntity('aaaaaaaaaaaaaaaaa'), 49 | new FunctionEntity('sum', 0, [new ValueEntity(1), new ValueEntity(2), new ValueEntity(3)]), 50 | ], 51 | }); 52 | expect(f.call()).toBe(6); 53 | }); 54 | }); 55 | describe('validation error', () => { 56 | it('missing argument', () => { 57 | const f = new IfErrorFunction({ table, args: [] }); 58 | expect(f.call.bind(f)).toThrow(FormulaError); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/iferror.ts: -------------------------------------------------------------------------------- 1 | // DO NOT COPY THIS CODE FOR THE OTHER. 2 | 3 | import { Table } from '../../lib/table'; 4 | import { Expression, FormulaError } from '../evaluator'; 5 | import { FunctionProps } from './__base'; 6 | import { stripTable } from './__utils'; 7 | 8 | export class IfErrorFunction { 9 | example = 'IFERROR(A1, "Error in cell A1")'; 10 | helpText = [ 11 | 'Returns the first argument if it is not an error value, otherwise returns the second argument if present, or a blank if the second argument is absent.', 12 | ]; 13 | helpArgs = [ 14 | { 15 | name: 'value', 16 | description: 'The value to return if value itself is not an error.', 17 | }, 18 | { 19 | name: 'value_if_error', 20 | description: 'The value the function returns if value is an error.', 21 | optional: true, 22 | }, 23 | ]; 24 | private args: Expression[]; 25 | private table: Table; 26 | 27 | constructor({ args, table }: FunctionProps) { 28 | this.args = args; 29 | this.table = table; 30 | } 31 | 32 | protected validate() { 33 | if (this.args.length === 1 || this.args.length === 2) { 34 | return; 35 | } 36 | throw new FormulaError( 37 | '#N/A', 38 | 'Number of arguments for IFERROR is incorrect. 1 or 2 argument(s) must be specified.', 39 | ); 40 | } 41 | 42 | public call() { 43 | this.validate(); 44 | const [value, valueIfError] = this.args; 45 | 46 | try { 47 | return stripTable(value.evaluate({ table: this.table })); 48 | } catch (e) { 49 | return stripTable(valueIfError?.evaluate({ table: this.table })); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/len.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureString } from './__utils'; 4 | 5 | export class LenFunction extends BaseFunction { 6 | example = 'LEN(A2)'; 7 | helpText = ['Returns the length of a string.']; 8 | helpArgs = [ 9 | { 10 | name: 'text', 11 | description: 'A text to be returned the length.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for LEN is incorrect.'); 18 | } 19 | this.bareArgs = [ensureString(this.bareArgs[0])]; 20 | } 21 | 22 | protected main(text: string) { 23 | return text.length; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/lenb.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureString } from './__utils'; 4 | 5 | export class LenbFunction extends BaseFunction { 6 | example = 'LENB(A2)'; 7 | helpText = ['Returns the number of bytes in the length of the string.']; 8 | helpArgs = [ 9 | { 10 | name: 'text', 11 | description: 'A text to be returned the length of the bytes.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for LENB is incorrect.'); 18 | } 19 | this.bareArgs = [ensureString(this.bareArgs[0])]; 20 | } 21 | 22 | protected main(text: string) { 23 | return encodeURIComponent(text).replace(/%../g, 'x').length; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/ln.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class LnFunction extends BaseFunction { 6 | example = 'LN(100)'; 7 | helpText = ['Returns the logarithm of e']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'The value for the logarithm of e', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for LN is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | if (this.bareArgs[0] <= 0) { 21 | throw new FormulaError('NUM!', 'value must be greater than 0'); 22 | } 23 | } 24 | 25 | protected main(value: number) { 26 | return Math.log(value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/log.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class LogFunction extends BaseFunction { 6 | example = 'LOG(128, 2)'; 7 | helpText = ['Returns the logarithm of a number whose base is the specified number.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'The value for the logarithm of the specified number as base.', 12 | }, 13 | { name: 'base', description: 'An exponent to power the base.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for LOG is incorrect.'); 19 | } 20 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 21 | if (this.bareArgs[0] <= 0) { 22 | throw new FormulaError('NUM!', 'value must be greater than 0'); 23 | } 24 | if (this.bareArgs[1] <= 1) { 25 | throw new FormulaError('NUM!', 'base must be greater than 1'); 26 | } 27 | } 28 | 29 | protected main(value: number, base: number) { 30 | return Math.log2(value) / Math.log2(base); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/log10.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class Log10Function extends BaseFunction { 6 | example = 'LOG10(100)'; 7 | helpText = ['Returns the logarithm of 10']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'The value for the logarithm of 10', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for LOG10 is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | if (this.bareArgs[0] <= 0) { 21 | throw new FormulaError('NUM!', 'value must be greater than 0'); 22 | } 23 | } 24 | 25 | protected main(value: number) { 26 | return Math.log10(value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/lt.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { lt } from './__utils'; 4 | 5 | export class LtFunction extends BaseFunction { 6 | example = 'LT(3, 6)'; 7 | helpText = [ 8 | 'Returns TRUE if the first argument is truely less than the second argument, FALSE otherwise.', 9 | "This is the same as the '<' operator.", 10 | ]; 11 | helpArgs = [ 12 | { name: 'value1', description: 'First value.' }, 13 | { name: 'value2', description: 'A value to be compared with value1.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for LT is incorrect.'); 19 | } 20 | } 21 | 22 | protected main(v1: number, v2: number) { 23 | return lt(v1, v2); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/lte.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { lte } from './__utils'; 4 | 5 | export class LteFunction extends BaseFunction { 6 | example = 'LTE(3, 6)'; 7 | helpText = [ 8 | 'Returns TRUE if the first argument is less than the second argument, FALSE otherwise.', 9 | "This is the same as the '<=' operator.", 10 | ]; 11 | helpArgs = [ 12 | { name: 'value1', description: 'First value.' }, 13 | { name: 'value2', description: 'A value to be compared with value1.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for LTE is incorrect.'); 19 | } 20 | } 21 | 22 | protected main(v1: number, v2: number) { 23 | return lte(v1, v2); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/max.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { ensureNumber } from './__utils'; 6 | 7 | export class MaxFunction extends BaseFunction { 8 | example = 'MAX(A2:A100, 101)'; 9 | helpText = ['Returns the max in a series of numbers or cells.']; 10 | helpArgs = [ 11 | { name: 'value1', description: 'First number or range.' }, 12 | { 13 | name: 'value2', 14 | description: 'Additional numbers or ranges', 15 | optional: true, 16 | iterable: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | if (this.bareArgs.length === 0) { 22 | throw new FormulaError('#N/A', 'Number of arguments must be greater than 0.'); 23 | } 24 | const spreaded: number[] = []; 25 | this.bareArgs.map((arg) => { 26 | if (arg instanceof Table) { 27 | spreaded.push( 28 | ...solveTable({ table: arg }) 29 | .reduce((a, b) => a.concat(b)) 30 | .filter((v: any) => typeof v === 'number'), 31 | ); 32 | return; 33 | } 34 | spreaded.push(ensureNumber(arg)); 35 | }); 36 | this.bareArgs = spreaded; 37 | } 38 | 39 | protected main(...values: number[]) { 40 | if (values.length === 0) { 41 | return 0; 42 | } 43 | return Math.max(...values); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/min.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { ensureNumber } from './__utils'; 6 | 7 | export class MinFunction extends BaseFunction { 8 | example = 'MIN(A2:A100, 101)'; 9 | helpText = ['Returns the min in a series of numbers or cells.']; 10 | helpArgs = [ 11 | { name: 'value1', description: 'First number or range.' }, 12 | { 13 | name: 'value2', 14 | description: 'Additional numbers or ranges', 15 | optional: true, 16 | iterable: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | if (this.bareArgs.length === 0) { 22 | throw new FormulaError('#N/A', 'Number of arguments must be greater than 0.'); 23 | } 24 | const spreaded: number[] = []; 25 | this.bareArgs.map((arg) => { 26 | if (arg instanceof Table) { 27 | spreaded.push( 28 | ...solveTable({ table: arg }) 29 | .reduce((a, b) => a.concat(b)) 30 | .filter((v: any) => typeof v === 'number'), 31 | ); 32 | return; 33 | } 34 | spreaded.push(ensureNumber(arg)); 35 | }); 36 | this.bareArgs = spreaded; 37 | } 38 | 39 | protected main(...values: number[]) { 40 | if (values.length === 0) { 41 | return 0; 42 | } 43 | return Math.min(...values); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/minus.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import { FormulaError } from '../evaluator'; 4 | import { TimeDelta } from '../../lib/time'; 5 | import { BaseFunction } from './__base'; 6 | import { ensureNumber, stripTable } from './__utils'; 7 | import { Table } from '../../lib/table'; 8 | import { SECONDS_IN_DAY } from '../../constants'; 9 | 10 | export class MinusFunction extends BaseFunction { 11 | example = 'MINUS(8, 3)'; 12 | helpText = ['Returns the difference of two numbers.', "This is the same as the '-' operator."]; 13 | helpArgs = [ 14 | { name: 'value1', description: 'A number that will be subtracted.' }, 15 | { name: 'value2', description: 'A number that will subtract from value1.' }, 16 | ]; 17 | 18 | protected validate() { 19 | if (this.bareArgs.length !== 2) { 20 | throw new FormulaError('#N/A', 'Number of arguments for MINUS is incorrect.'); 21 | } 22 | this.bareArgs = this.bareArgs.map((arg) => { 23 | if (arg instanceof Table) { 24 | arg = stripTable(arg, 0, 0); 25 | } 26 | return typeof arg === 'object' ? arg : ensureNumber(arg); 27 | }); 28 | } 29 | 30 | protected main(v1: number | Date | TimeDelta, v2: number | Date | TimeDelta) { 31 | if (typeof v1 === 'number' && typeof v2 === 'number') { 32 | return v1 - v2; 33 | } 34 | if (v1 instanceof Date && v2 instanceof Date) { 35 | return new TimeDelta(v1, v2); 36 | } 37 | if (v1 instanceof Date && TimeDelta.is(v2)) { 38 | return TimeDelta.ensure(v2).sub(v1); 39 | } 40 | if (TimeDelta.is(v1) && v2 instanceof Date) { 41 | return TimeDelta.ensure(v1).sub(v2); 42 | } 43 | if (v1 instanceof Date && typeof v2 === 'number') { 44 | return dayjs(v1) 45 | .subtract(v2 * SECONDS_IN_DAY, 'second') 46 | .toDate(); 47 | } 48 | if (!v1) { 49 | return -v2; 50 | } 51 | if (!v2) { 52 | return v1; 53 | } 54 | throw new FormulaError('#VALUE!', 'Mismatched types for minuend and subtrahend.'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/mod.spec.ts: -------------------------------------------------------------------------------- 1 | import { ModFunction } from './mod'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('mod', () => { 6 | const table = new Table({}); 7 | table.initialize({ A1: { value: 5 }, A2: { value: -3 }, B2: { value: 25 } }); 8 | describe('normal', () => { 9 | it('divided by positive value', () => { 10 | { 11 | const f = new ModFunction({ 12 | table, 13 | args: [new RefEntity('B2'), new ValueEntity(5)], 14 | }); 15 | expect(f.call()).toBe(0); 16 | } 17 | { 18 | const f = new ModFunction({ 19 | table, 20 | args: [new ValueEntity(12), new RefEntity('A1')], 21 | }); 22 | expect(f.call()).toBe(2); 23 | } 24 | { 25 | const f = new ModFunction({ 26 | table, 27 | args: [new ValueEntity(-5), new ValueEntity(4)], 28 | }); 29 | expect(f.call()).toBe(3); 30 | } 31 | }); 32 | it('divided by negative value', () => { 33 | { 34 | const f = new ModFunction({ 35 | table, 36 | args: [new ValueEntity(10), new RefEntity('A2')], 37 | }); 38 | expect(f.call()).toBe(-2); 39 | } 40 | { 41 | const f = new ModFunction({ 42 | table, 43 | args: [new ValueEntity(-10), new RefEntity('A2')], 44 | }); 45 | expect(f.call()).toBe(-1); 46 | } 47 | }); 48 | }); 49 | describe('validation error', () => { 50 | it('missing argument', () => { 51 | { 52 | const f = new ModFunction({ table, args: [new ValueEntity(3)] }); 53 | expect(f.call.bind(f)).toThrow(FormulaError); 54 | } 55 | { 56 | const f = new ModFunction({ table, args: [new ValueEntity(3)] }); 57 | expect(f.call.bind(f)).toThrow(FormulaError); 58 | } 59 | }); 60 | it('division by zero', () => { 61 | const f = new ModFunction({ table, args: [new ValueEntity(5), new ValueEntity(0)] }); 62 | expect(f.call.bind(f)).toThrow(FormulaError); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/mod.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class ModFunction extends BaseFunction { 6 | example = 'MOD(10, 4)'; 7 | helpText = ['Returns the result of the modulo operation.']; 8 | helpArgs = [ 9 | { 10 | name: 'dividend', 11 | description: 'A number that will be divided by divisor.', 12 | }, 13 | { name: 'divisor', description: 'A number that will divide a dividend.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for MOD is incorrect.'); 19 | } 20 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 21 | if (this.bareArgs[1] === 0) { 22 | throw new FormulaError('#DIV/0!', 'The second argument must be non-zero.'); 23 | } 24 | } 25 | 26 | protected main(v1: number, v2: number) { 27 | // https://stackoverflow.com/questions/4467539/javascript-modulo-gives-a-negative-result-for-negative-numbers 28 | return ((v1 % v2) + v2) % v2; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/multiply.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class MultiplyFunction extends BaseFunction { 6 | example = 'MULTIPLY(6, 7)'; 7 | helpText = ['Returns the product of two numbers.', "This is the same as the '*' operator."]; 8 | helpArgs = [ 9 | { name: 'factor1', description: 'First factor.' }, 10 | { name: 'factor2', description: 'Second factor.' }, 11 | ]; 12 | 13 | protected validate() { 14 | if (this.bareArgs.length !== 2) { 15 | throw new FormulaError('#N/A', 'Number of arguments for MULTIPLY is incorrect.'); 16 | } 17 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 18 | } 19 | 20 | protected main(v1: number, v2: number) { 21 | return v1 * v2; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/ne.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ne } from './__utils'; 4 | 5 | export class NeFunction extends BaseFunction { 6 | example = 'NE(6, 7)'; 7 | helpText = [ 8 | 'Returns TRUE if the two specified values are not equal, FALSE if they are.', 9 | "This is the same as the '<>' operator.", 10 | ]; 11 | helpArgs = [ 12 | { name: 'value1', description: 'First value.' }, 13 | { name: 'value2', description: 'A value to be compared with value1.' }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length !== 2) { 18 | throw new FormulaError('#N/A', 'Number of arguments for NE is incorrect.'); 19 | } 20 | } 21 | 22 | protected main(v1: number, v2: number) { 23 | return ne(v1, v2); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/not.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureBoolean } from './__utils'; 4 | 5 | export class NotFunction extends BaseFunction { 6 | example = 'NOT(TRUE)'; 7 | helpText = ['Returns the inverse of the Boolean; if TRUE, NOT returns FALSE.', 'If FALSE, NOT returns TRUE.']; 8 | helpArgs = [ 9 | { 10 | name: 'logical expression', 11 | description: 'A logical expression as a boolean.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length === 1) { 17 | this.bareArgs[0] = ensureBoolean(this.bareArgs[0]); 18 | return; 19 | } 20 | throw new FormulaError('#N/A', 'Number of arguments for NOT is incorrect. 1 argument must be specified.'); 21 | } 22 | 23 | protected main(v1: boolean) { 24 | return !v1; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/now.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | 4 | export class NowFunction extends BaseFunction { 5 | example = 'NOW()'; 6 | helpText = ['Returns a serial value corresponding to the current date and time.']; 7 | helpArgs = []; 8 | 9 | protected validate() { 10 | if (this.bareArgs.length !== 0) { 11 | throw new FormulaError('#N/A', 'Number of arguments for NOW is incorrect.'); 12 | } 13 | } 14 | 15 | protected main() { 16 | return new Date(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/or.ts: -------------------------------------------------------------------------------- 1 | import { BaseFunction } from './__base'; 2 | import { ensureBoolean } from './__utils'; 3 | 4 | export class OrFunction extends BaseFunction { 5 | example = 'OR(A1=1, A2=2)'; 6 | helpText = ['Returns TRUE if any argument is logically true.', 'Returns FALSE if all arguments are logically false.']; 7 | helpArgs = [ 8 | { name: 'expression1', description: 'First logical expression.' }, 9 | { 10 | name: 'expression2', 11 | description: 'Additional expressions', 12 | optional: true, 13 | iterable: true, 14 | }, 15 | ]; 16 | 17 | protected validate() { 18 | this.bareArgs = this.bareArgs.map((arg) => ensureBoolean(arg)); 19 | } 20 | 21 | protected main(...values: boolean[]) { 22 | return values.reduce((a, b) => a || b); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/pi.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | 4 | export class PiFunction extends BaseFunction { 5 | example = 'PI()'; 6 | helpText = ['Returns the value of pi.']; 7 | helpArgs = []; 8 | 9 | protected validate() { 10 | if (this.bareArgs.length !== 0) { 11 | throw new FormulaError('#N/A', 'Number of arguments for PI is incorrect.'); 12 | } 13 | } 14 | 15 | protected main() { 16 | return Math.PI; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/power.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class PowerFunction extends BaseFunction { 6 | example = 'POWER(4,0.5)'; 7 | helpText = ['Returns a number multiplied by an exponent.']; 8 | helpArgs = [ 9 | { name: 'base', description: 'A number to be multiplied by an exponent.' }, 10 | { name: 'exponent', description: 'An exponent to power the base.' }, 11 | ]; 12 | 13 | protected validate() { 14 | if (this.bareArgs.length !== 2) { 15 | throw new FormulaError('#N/A', 'Number of arguments for POWER is incorrect.'); 16 | } 17 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 18 | } 19 | 20 | protected main(base: number, exponent: number) { 21 | return Math.pow(base, exponent); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/product.ts: -------------------------------------------------------------------------------- 1 | import { solveTable } from '../solver'; 2 | import { Table } from '../../lib/table'; 3 | import { BaseFunction } from './__base'; 4 | import { ensureNumber } from './__utils'; 5 | 6 | export class ProductFunction extends BaseFunction { 7 | example = 'PRODUCT(A2:A100)'; 8 | helpText = ['Returns the product of a series of numbers or cells.']; 9 | helpArgs = [ 10 | { name: 'value1', description: 'First number or range.' }, 11 | { 12 | name: 'value2', 13 | description: 'Additional numbers or ranges', 14 | optional: true, 15 | iterable: true, 16 | }, 17 | ]; 18 | 19 | protected validate() { 20 | const spreaded: number[] = []; 21 | this.bareArgs.forEach((arg) => { 22 | if (arg instanceof Table) { 23 | spreaded.push( 24 | ...solveTable({ table: arg }) 25 | .reduce((a, b) => a.concat(b)) 26 | .filter((v: any) => typeof v === 'number'), 27 | ); 28 | return; 29 | } 30 | spreaded.push(ensureNumber(arg)); 31 | }); 32 | this.bareArgs = spreaded; 33 | } 34 | 35 | protected main(...values: number[]) { 36 | return values.reduce((a, b) => a * b); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/radians.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class RadiansFunction extends BaseFunction { 6 | example = 'RADIANS(180)'; 7 | helpText = ['Converts an angle from degrees to radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'angle', 11 | description: 'The angle to convert from degrees to radians.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for RADIANS is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | } 21 | 22 | protected main(angle: number) { 23 | return (angle / 180) * Math.PI; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/rand.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | 4 | export class RandFunction extends BaseFunction { 5 | example = 'RAND()'; 6 | helpText = ['Returns a random number between 0 and 1.']; 7 | helpArgs = []; 8 | 9 | protected validate() { 10 | if (this.bareArgs.length !== 0) { 11 | throw new FormulaError('#N/A', 'Number of arguments for RAND is incorrect.'); 12 | } 13 | } 14 | 15 | protected main() { 16 | return Math.random(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/round.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class RoundFunction extends BaseFunction { 6 | example = 'ROUND(99.44,1)'; 7 | helpText = ['Round a number to the specified number of decimal places according to standard rules.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A number to be rounded.', 12 | }, 13 | { 14 | name: 'digit', 15 | description: 'The number of decimal places after rounding.', 16 | optional: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | if (this.bareArgs.length !== 1 && this.bareArgs.length !== 2) { 22 | throw new FormulaError('#N/A', 'Number of arguments for ROUND is incorrect.'); 23 | } 24 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 25 | } 26 | 27 | protected main(value: number, digit = 0) { 28 | const multiplier = Math.pow(10, digit); 29 | return Math.round(value * multiplier) / multiplier; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/rounddown.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class RounddownFunction extends BaseFunction { 6 | example = 'ROUNDDOWN(99.44,1)'; 7 | helpText = ['Round down a number to the specified number of decimal places according to standard rules.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A number to be rounded down.', 12 | }, 13 | { 14 | name: 'digit', 15 | description: 'The number of decimal places after rounding.', 16 | optional: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | if (this.bareArgs.length !== 1 && this.bareArgs.length !== 2) { 22 | throw new FormulaError('#N/A', 'Number of arguments for ROUNDDOWN is incorrect.'); 23 | } 24 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 25 | } 26 | 27 | protected main(value: number, digit = 0) { 28 | const multiplier = Math.pow(10, digit); 29 | return Math.floor(value * multiplier) / multiplier; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/roundup.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class RoundupFunction extends BaseFunction { 6 | example = 'ROUNDUP(99.44,1)'; 7 | helpText = ['Round up a number to the specified number of decimal places according to standard rules.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A number to be rounded up.', 12 | }, 13 | { 14 | name: 'digit', 15 | description: 'The number of decimal places after rounding.', 16 | optional: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | if (this.bareArgs.length !== 1 && this.bareArgs.length !== 2) { 22 | throw new FormulaError('#N/A', 'Number of arguments for ROUNDUP is incorrect.'); 23 | } 24 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 25 | } 26 | 27 | protected main(value: number, digit = 0) { 28 | const multiplier = Math.pow(10, digit); 29 | return Math.ceil(value * multiplier) / multiplier; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/row.spec.ts: -------------------------------------------------------------------------------- 1 | import { RowFunction } from './row'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, RangeEntity, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('row', () => { 6 | const table = new Table({}); 7 | table.initialize({ 8 | A4: { value: 9999 }, 9 | }); 10 | describe('normal', () => { 11 | it('eq', () => { 12 | { 13 | const f = new RowFunction({ 14 | table, 15 | args: [new RefEntity('C100')], 16 | }); 17 | expect(f.call()).toBe(100); 18 | } 19 | { 20 | const f = new RowFunction({ 21 | table, 22 | args: [], 23 | origin: { x: 5, y: 3 }, 24 | }); 25 | expect(f.call()).toBe(3); 26 | } 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/row.ts: -------------------------------------------------------------------------------- 1 | import { Table } from '../../lib/table'; 2 | import { FormulaError } from '../evaluator'; 3 | import { BaseFunction } from './__base'; 4 | 5 | export class RowFunction extends BaseFunction { 6 | example = 'ROW(A9)'; 7 | helpText = ['Returns the row number of a specified cell.']; 8 | helpArgs = [ 9 | { 10 | name: 'cell_reference', 11 | description: 'The cell whose row number will be returned.', 12 | option: true, 13 | }, 14 | ]; 15 | 16 | protected validate() { 17 | if (this.bareArgs.length === 0) { 18 | this.bareArgs = [this.origin?.y ?? 1]; 19 | } else if (this.bareArgs.length === 1) { 20 | const table = this.bareArgs[0] as Table; 21 | this.bareArgs = [table.top]; 22 | } else { 23 | throw new FormulaError('#N/A', 'Number of arguments for ROW is incorrect.'); 24 | } 25 | } 26 | 27 | protected main(top: number) { 28 | return top; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/sin.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class SinFunction extends BaseFunction { 6 | example = 'SIN(PI()/2)'; 7 | helpText = ['Returns the sin of the angle specified in radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'angle', 11 | description: 'An angle in radians, at which you want the sin.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for SIN is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | } 21 | 22 | protected main(angle: number) { 23 | return Math.sin(angle); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/sqrt.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class SqrtFunction extends BaseFunction { 6 | example = 'SQRT(9)'; 7 | helpText = ['Returns the positive square root of a positive number.']; 8 | helpArgs = [ 9 | { 10 | name: 'value', 11 | description: 'A number for which the positive square root is to be found.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for SQRT is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | if (this.bareArgs[0] < 0) { 21 | throw new FormulaError('NUM!', 'First argument must be positive.'); 22 | } 23 | } 24 | 25 | protected main(value: number) { 26 | return Math.sqrt(value); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/sum.spec.ts: -------------------------------------------------------------------------------- 1 | import { SumFunction } from './sum'; 2 | import { Table } from '../../lib/table'; 3 | import { FormulaError, RangeEntity, RefEntity, ValueEntity } from '../evaluator'; 4 | 5 | describe('sum', () => { 6 | const table = new Table({}); 7 | table.initialize({ 8 | A1: { value: 5 }, 9 | A2: { value: '=-(9 * 10) - 4' }, 10 | B50: { value: 25 }, 11 | C15: { value: 'not a number' }, 12 | C20: { value: 10 }, 13 | }); 14 | 15 | describe('normal', () => { 16 | it('sum single values', () => { 17 | const f = new SumFunction({ 18 | table, 19 | args: [new RefEntity('B50'), new ValueEntity(5), new ValueEntity(-3)], 20 | }); 21 | expect(f.call()).toBe(27); 22 | }); 23 | it('sum range', () => { 24 | const f = new SumFunction({ 25 | table, 26 | args: [new RangeEntity('A2:E20'), new ValueEntity(30)], 27 | }); 28 | // -90 - 4 + 10 + 30 29 | expect(f.call()).toBe(-54); 30 | }); 31 | }); 32 | describe('validation error', () => { 33 | it('missing argument', () => { 34 | const f = new SumFunction({ table, args: [] }); 35 | expect(f.call.bind(f)).toThrow(FormulaError); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/sum.ts: -------------------------------------------------------------------------------- 1 | import { solveTable } from '../solver'; 2 | import { Table } from '../../lib/table'; 3 | import { BaseFunction } from './__base'; 4 | import { ensureNumber } from './__utils'; 5 | import { FormulaError } from '../evaluator'; 6 | 7 | export class SumFunction extends BaseFunction { 8 | example = 'SUM(A2:A100, 101)'; 9 | helpText = ['Returns the sum of a series of numbers or cells.']; 10 | helpArgs = [ 11 | { name: 'value1', description: 'First number or range.' }, 12 | { 13 | name: 'value2', 14 | description: 'Additional numbers or ranges', 15 | optional: true, 16 | iterable: true, 17 | }, 18 | ]; 19 | 20 | protected validate() { 21 | if (this.bareArgs.length === 0) { 22 | throw new FormulaError('#N/A', 'One or more arguments are required.'); 23 | } 24 | const spreaded: number[] = []; 25 | this.bareArgs.forEach((arg) => { 26 | if (arg instanceof Table) { 27 | spreaded.push( 28 | ...solveTable({ table: arg }) 29 | .reduce((a, b) => a.concat(b)) 30 | .filter((v: any) => typeof v === 'number'), 31 | ); 32 | return; 33 | } 34 | spreaded.push(ensureNumber(arg)); 35 | }); 36 | this.bareArgs = spreaded; 37 | } 38 | 39 | protected main(...values: number[]) { 40 | if (values.length === 0) { 41 | return 0; 42 | } 43 | return values.reduce((a, b) => a + b); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/sumif.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { check, ensureString } from './__utils'; 6 | import { AreaType } from '../../types'; 7 | 8 | export class SumifFunction extends BaseFunction { 9 | example = 'SUMIF(A1:A10,">20")'; 10 | helpText = ['Returns the sum of a series of cells.']; 11 | helpArgs = [ 12 | { name: 'range1', description: 'A condition range.' }, 13 | { 14 | name: 'condition', 15 | description: 'A condition for summarization.', 16 | }, 17 | { 18 | name: 'range2', 19 | description: 'A range to be summarized.', 20 | optional: true, 21 | }, 22 | ]; 23 | 24 | protected validate() { 25 | if (this.bareArgs.length !== 2 && this.bareArgs.length !== 3) { 26 | throw new FormulaError('#N/A', 'Number of arguments for SUMIF is incorrect.'); 27 | } 28 | if (this.bareArgs[2] != undefined && this.bareArgs[2] instanceof Table) { 29 | throw new FormulaError('#N/A', '3rd argument must be range.'); 30 | } 31 | this.bareArgs[1] = ensureString(this.bareArgs[1]); 32 | } 33 | 34 | protected main(range: Table, condition: string, sumRange: Table) { 35 | if (!(range instanceof Table)) { 36 | return check(range, condition) ? range : 0; 37 | } 38 | const conditionMatrix = solveTable({ table: range }); 39 | let sumMatrix = conditionMatrix; 40 | if (sumRange) { 41 | const [top, left] = [sumRange.top, sumRange.left]; 42 | const area: AreaType = { 43 | top, 44 | left, 45 | bottom: top + sumRange.getNumRows(), 46 | right: left + sumRange.getNumCols(), 47 | }; 48 | sumMatrix = solveTable({ table: this.table.trim(area) }); 49 | } 50 | let total = 0; 51 | conditionMatrix.forEach((row, y) => 52 | row.forEach((c, x) => { 53 | const s = sumMatrix[y]?.[x] || 0; 54 | if (typeof s === 'number' && check(c, condition)) { 55 | total += s; 56 | } 57 | }), 58 | ); 59 | return total; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/tan.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class TanFunction extends BaseFunction { 6 | example = 'TAN(1)'; 7 | helpText = ['Returns the tan of the angle specified in radians.']; 8 | helpArgs = [ 9 | { 10 | name: 'angle', 11 | description: 'An angle in radians, at which you want the tan.', 12 | }, 13 | ]; 14 | 15 | protected validate() { 16 | if (this.bareArgs.length !== 1) { 17 | throw new FormulaError('#N/A', 'Number of arguments for TAN is incorrect.'); 18 | } 19 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 20 | } 21 | 22 | protected main(angle: number) { 23 | return Math.tan(angle); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/uminus.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { BaseFunction } from './__base'; 3 | import { ensureNumber } from './__utils'; 4 | 5 | export class UminusFunction extends BaseFunction { 6 | example = 'UMINUS(4)'; 7 | helpText = ['Returns a number with positive and negative values reversed.']; 8 | helpArgs = [{ name: 'value1', description: 'A number that will be subtracted.' }]; 9 | 10 | protected validate() { 11 | if (this.bareArgs.length !== 1) { 12 | throw new FormulaError('#N/A', 'A single numerical value is only required.'); 13 | } 14 | this.bareArgs = this.bareArgs.map((arg) => ensureNumber(arg)); 15 | } 16 | 17 | protected main(v1: number) { 18 | return -v1; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/react-core/formula/functions/vlookup.ts: -------------------------------------------------------------------------------- 1 | import { FormulaError } from '../evaluator'; 2 | import { solveTable } from '../solver'; 3 | import { Table } from '../../lib/table'; 4 | import { BaseFunction } from './__base'; 5 | import { ensureBoolean, ensureNumber, stripTable } from './__utils'; 6 | 7 | export class VlookupFunction extends BaseFunction { 8 | example = 'VLOOKUP(10003, A2:B26, 2, FALSE)'; 9 | helpText = [ 10 | 'Searches vertically for the specified key in the first column of the range and returns the value of the specified cell in the same row.', 11 | ]; 12 | helpArgs = [ 13 | { name: 'key', description: 'Search key.' }, 14 | { 15 | name: 'range', 16 | description: 'A range for search', 17 | }, 18 | { 19 | name: 'index', 20 | description: 'The index of the column in the range.', 21 | }, 22 | { 23 | name: 'is_sorted', 24 | description: 25 | 'FALSE: Exact match. This is recommended. TRUE: Approximate match. Before you use an approximate match, sort your search key in ascending order. Otherwise, you may likely get a wrong return value.', 26 | option: true, 27 | }, 28 | ]; 29 | 30 | protected validate() { 31 | if (this.bareArgs.length !== 3 && this.bareArgs.length !== 4) { 32 | throw new FormulaError('#N/A', 'Number of arguments for VLOOKUP is incorrect.'); 33 | } 34 | if (this.bareArgs[0] instanceof Table) { 35 | this.bareArgs[0] = stripTable(this.bareArgs[0]); 36 | } 37 | if (!(this.bareArgs[1] instanceof Table)) { 38 | throw new FormulaError('#REF!', '2nd argument must be range'); 39 | } 40 | this.bareArgs[2] = ensureNumber(this.bareArgs[2]); 41 | this.bareArgs[3] = ensureBoolean(this.bareArgs[3], true); 42 | } 43 | 44 | protected main(key: any, range: Table, index: number, isSorted: boolean) { 45 | const matrix = solveTable({ table: range }); 46 | if (isSorted) { 47 | let last = -1; 48 | for (let y = 0; y <= range.getNumRows(); y++) { 49 | const v = matrix[y]?.[0]; 50 | if (v == null) { 51 | continue; 52 | } 53 | if (v <= key) { 54 | last = y; 55 | } else { 56 | break; 57 | } 58 | } 59 | if (last !== -1) { 60 | return matrix[last]?.[index - 1]; 61 | } 62 | } else { 63 | for (let y = 0; y <= range.getNumRows(); y++) { 64 | if (matrix[y]?.[0] === key) { 65 | return matrix[y]?.[index - 1]; 66 | } 67 | } 68 | } 69 | throw new FormulaError('#N/A', `No values found for '${key}'.`); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/react-core/formula/solver.ts: -------------------------------------------------------------------------------- 1 | import { Special } from '../constants'; 2 | import { Table } from '../lib/table'; 3 | import { MatrixType, PointType } from '../types'; 4 | import { FormulaError, Lexer, Parser } from './evaluator'; 5 | import { p2a } from '../lib/converters'; 6 | 7 | const SOLVING = new Special('solving'); 8 | 9 | type SolveFormulaType = { 10 | value: any; 11 | table: Table; 12 | raise?: boolean; 13 | evaluates?: boolean | null; 14 | origin?: PointType; 15 | }; 16 | 17 | export const solveFormula = ({ value, table, raise = true, evaluates = true, origin }: SolveFormulaType) => { 18 | if (evaluates === null) { 19 | return value; 20 | } 21 | let solved = value; 22 | if (typeof value === 'string') { 23 | if (value.charAt(0) === '=') { 24 | try { 25 | const lexer = new Lexer(value.substring(1), { origin }); 26 | lexer.tokenize(); 27 | const parser = new Parser(lexer.tokens); 28 | if (evaluates === false) { 29 | return '=' + lexer.stringifyToRef(table); 30 | } 31 | const expr = parser.build(); 32 | solved = expr?.evaluate?.({ table }); 33 | } catch (e) { 34 | if (raise) { 35 | throw e; 36 | } 37 | return null; 38 | } 39 | } 40 | } 41 | if (solved instanceof Table) { 42 | solved = solveTable({ table: solved, raise })[0][0]; 43 | } 44 | return solved; 45 | }; 46 | 47 | export const solveTable = ({ table, raise = true }: { table: Table; raise?: boolean }): MatrixType => { 48 | const area = table.getArea(); 49 | return table.getMatrixFlatten({ area, evaluates: null }).map((row, i) => { 50 | const y = area.top + i; 51 | return row.map((value, j) => { 52 | const x = area.left + j; 53 | const address = p2a({ y, x }); 54 | const cache = table.getSolvedCache(address); 55 | 56 | try { 57 | if (cache === SOLVING) { 58 | throw new FormulaError('#REF!', 'References are circulating.', new Error(value as string)); 59 | } else if (cache instanceof FormulaError) { 60 | throw cache; 61 | } else if (cache != null) { 62 | return cache; 63 | } 64 | table.setSolvedCache(address, SOLVING); 65 | const solved = solveFormula({ value, table, raise, origin: { y, x } }); 66 | table.setSolvedCache(address, solved); 67 | return solved; 68 | } catch (e) { 69 | table.setSolvedCache(address, e); 70 | if (raise) { 71 | throw e; 72 | } 73 | return null; 74 | } 75 | }); 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /packages/react-core/generate-style.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { execSync } = require('child_process'); 3 | 4 | const generate = () => { 5 | execSync('pnpm less'); 6 | const css = fs.readFileSync('./styles/root.min.css'); 7 | const time = Math.floor((new Date()).getTime() / 1000); 8 | fs.writeFileSync('./styles/minified.ts', 9 | `// pnpm generate-style\nexport const LAST_MODIFIED = ${time};\nexport const CSS = \`${css}\`;\n` 10 | ); 11 | }; 12 | 13 | generate(); 14 | -------------------------------------------------------------------------------- /packages/react-core/index.ts: -------------------------------------------------------------------------------- 1 | export { GridSheet } from './components/GridSheet'; 2 | export { createTableRef } from './components/Tabular'; 3 | export { Renderer } from './renderers/core'; 4 | export type { RendererMixinType } from './renderers/core'; 5 | export { Parser } from './parsers/core'; 6 | export type { ParserMixinType } from './parsers/core'; 7 | export { 8 | oa2aa, 9 | aa2oa, 10 | constructInitialCells, 11 | constructInitialCellsOrigin, 12 | zoneToArea, 13 | areaToZone, 14 | areaToRange, 15 | } from './lib/structs'; 16 | 17 | export { TimeDelta } from './lib/time'; 18 | export { x2c, c2x, y2r, r2y, p2a, a2p } from './lib/converters'; 19 | export { updateTable } from './store/actions'; 20 | export { PluginBase, useInitialPluginContext, usePluginContext } from './components/PluginBase'; 21 | export type { 22 | MatrixType, 23 | CellType, 24 | FeedbackType, 25 | OptionsType, 26 | WriterType, 27 | CellsByAddressType, 28 | CellsByIdType, 29 | ModeType, 30 | HeadersType, 31 | HistoryType, 32 | StoreType, 33 | PointType, 34 | AreaType, 35 | ZoneType, 36 | } from './types'; 37 | 38 | export type { Dispatcher } from './store'; 39 | export { ThousandSeparatorRendererMixin } from './renderers/thousand_separator'; 40 | export { CheckboxRendererMixin } from './renderers/checkbox'; 41 | export { BaseFunction } from './formula/functions/__base'; 42 | export { Table } from './lib/table'; 43 | export * as prevention from './lib/prevention'; 44 | export { SheetProvider } from './components/SheetProvider'; 45 | -------------------------------------------------------------------------------- /packages/react-core/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | 4 | preset: "ts-jest", 5 | "roots": [ 6 | "<rootDir>/" 7 | ], 8 | "testMatch": [ 9 | "**/__tests__/**/*.+(ts|tsx|js)", 10 | "**/?(*.)+(spec|test).+(ts|tsx|js)" 11 | ], 12 | "transform": { 13 | "^.+\\.(ts|tsx)$": "ts-jest" 14 | }, 15 | testEnvironment: 'jest-environment-jsdom' 16 | }; 17 | 18 | -------------------------------------------------------------------------------- /packages/react-core/lib/clipboard.ts: -------------------------------------------------------------------------------- 1 | import type { StoreType, AreaType } from '../types'; 2 | 3 | import { zoneToArea } from './structs'; 4 | import { solveTable } from '../formula/solver'; 5 | import { Table } from './table'; 6 | 7 | export const clip = (store: StoreType): AreaType => { 8 | const { selectingZone, choosing, editorRef, table } = store; 9 | const { y, x } = choosing; 10 | const selectingArea = zoneToArea(selectingZone); 11 | let area = selectingArea; 12 | if (area.left === -1) { 13 | area = { top: y, left: x, bottom: y, right: x }; 14 | } 15 | const input = editorRef.current; 16 | const trimmed = table.trim(area); 17 | const tsv = table2tsv(trimmed); 18 | const html = table2html(trimmed); 19 | 20 | if (navigator.clipboard) { 21 | const tsvBlob = new Blob([tsv], { type: 'text/plain' }); 22 | const htmlBlob = new Blob([html], { type: 'text/html' }); 23 | 24 | navigator.clipboard.write([ 25 | new ClipboardItem({ 26 | 'text/plain': tsvBlob, 27 | 'text/html': htmlBlob, 28 | }), 29 | ]); 30 | } else if (input != null) { 31 | input.value = tsv; 32 | input.focus(); 33 | input.select(); 34 | document.execCommand('copy'); 35 | input.value = ''; 36 | input.blur(); 37 | } 38 | return area; 39 | }; 40 | 41 | const table2tsv = (table: Table): string => { 42 | const lines: string[] = []; 43 | const matrix = solveTable({ table, raise: false }); 44 | matrix.forEach((row, i) => { 45 | const y = table.top + i; 46 | const cols: string[] = []; 47 | row.forEach((col, j) => { 48 | const x = table.left + j; 49 | const value = table.stringify({ y, x }, col); 50 | if (value.indexOf('\n') !== -1) { 51 | cols.push(`"${value.replace(/"/g, '""')}"`); 52 | } else { 53 | cols.push(value); 54 | } 55 | }); 56 | lines.push(cols.join('\t')); 57 | }); 58 | return lines.join('\n'); 59 | }; 60 | 61 | const table2html = (table: Table): string => { 62 | const lines: string[] = []; 63 | const matrix = solveTable({ table, raise: false }); 64 | matrix.forEach((row, i) => { 65 | const y = table.top + i; 66 | const cols: string[] = []; 67 | row.forEach((col, j) => { 68 | const x = table.left + j; 69 | const value = table.stringify({ y, x }, col); 70 | const valueEscaped = value 71 | .replace(/&/g, '&') 72 | .replace(/"/g, '"') 73 | .replace(/'/g, ''') 74 | .replace(/</g, '<') 75 | .replace(/>/g, '>'); 76 | cols.push(`<td>${valueEscaped}</td>`); 77 | }); 78 | lines.push(`<tr>${cols.join('')}</tr>`); 79 | }); 80 | return `<table>${lines.join('')}</table>`; 81 | }; 82 | -------------------------------------------------------------------------------- /packages/react-core/lib/input.ts: -------------------------------------------------------------------------------- 1 | import { Lexer } from '../formula/evaluator'; 2 | 3 | export const insertTextAtCursor = (input: HTMLTextAreaElement, text: string) => { 4 | input.focus(); 5 | const deprecated = !document.execCommand?.('insertText', false, text); 6 | if (!deprecated) { 7 | return; 8 | } 9 | input.setRangeText(text, input.selectionStart, input.selectionEnd, 'end'); 10 | return; 11 | }; 12 | 13 | export const insertRef = (input: HTMLTextAreaElement | null, ref: string, dryRun = false): boolean => { 14 | // dryRun is used to check if the ref can be inserted without actually inserting it 15 | if (!input?.value?.startsWith('=') || input.selectionStart === 0) { 16 | return false; 17 | } 18 | const lexer = new Lexer(input.value.substring(1)); 19 | lexer.tokenize(); 20 | const tokenIndex = lexer.getTokenIndexByCharPosition(input.selectionStart - 1); 21 | let token = lexer.tokens[tokenIndex]; 22 | if (token?.type === 'SPACE') { 23 | token = lexer.tokens[tokenIndex - 1]; 24 | } 25 | if ( 26 | token == null || 27 | token.type === 'OPEN' || 28 | token.type === 'COMMA' || 29 | token.type === 'INFIX_OPERATOR' || 30 | token.type === 'PREFIX_OPERATOR' 31 | ) { 32 | if (!dryRun) { 33 | insertTextAtCursor(input, ref); 34 | } 35 | } else if (token.type === 'REF' || token.type === 'RANGE') { 36 | if (!dryRun) { 37 | const [start, end] = lexer.getTokenPositionRange(tokenIndex + 1, 1); 38 | input.setSelectionRange(start, end); 39 | insertTextAtCursor(input, ref); 40 | } 41 | } else { 42 | return false; 43 | } 44 | return true; 45 | }; 46 | 47 | export const isRefInsertable = (input: HTMLTextAreaElement | null): boolean => { 48 | return insertRef(input, '', true); 49 | }; 50 | 51 | export const expandInput = (input: HTMLTextAreaElement) => { 52 | input.style.width = `${input.scrollWidth}px`; 53 | input.style.height = `${input.scrollHeight}px`; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/react-core/lib/palette.ts: -------------------------------------------------------------------------------- 1 | export const REF_PALETTE = [ 2 | // orange 3 | '#FF6600', 4 | // purple 5 | '#AA44FF', 6 | // emeraldblue 7 | '#00CCCC', 8 | // peach 9 | '#EEAAEE', 10 | // yellow 11 | '#DDDD00', 12 | // winered 13 | '#AA4444', 14 | // lightgreen 15 | '#00FF00', 16 | // pink 17 | '#FF00FF', 18 | // navy 19 | '#3366FF', 20 | ]; 21 | -------------------------------------------------------------------------------- /packages/react-core/lib/prevention.ts: -------------------------------------------------------------------------------- 1 | import { Prevention } from '../types'; 2 | 3 | export const DeleteRow: Prevention = 0b00000000000000000000000000000000000000000000000000001, // 1 4 | DeleteCol: Prevention = 0b00000000000000000000000000000000000000000000000000010, // 2 5 | AddRowAbove: Prevention = 0b00000000000000000000000000000000000000000000000000100, // 4 6 | AddRowBelow: Prevention = 0b00000000000000000000000000000000000000000000000001000, // 8 7 | AddColLeft: Prevention = 0b00000000000000000000000000000000000000000000000010000, // 16 8 | AddColRight: Prevention = 0b00000000000000000000000000000000000000000000000100000, // 32 9 | MoveFrom: Prevention = 0b00000000000000000000000000000000000000000000001000000, // 64 10 | MoveTo: Prevention = 0b00000000000000000000000000000000000000000000010000000, // 128 11 | Write: Prevention = 0b00000000000000000000000000000000000000000000100000000, // 256 12 | Style: Prevention = 0b00000000000000000000000000000000000000000001000000000, // 512 13 | Resize: Prevention = 0b00000000000000000000000000000000000000000010000000000, // 1024 14 | SetRenderer: Prevention = 0b00000000000000000000000000000000000000000100000000000, // 2048 15 | SetParser: Prevention = 0b00000000000000000000000000000000000000001000000000000; // 4096 16 | 17 | export const Move: Prevention = MoveFrom | MoveTo; // 192 18 | 19 | export const Update: Prevention = Write | Style | Resize | SetRenderer | SetParser; // 1792 20 | 21 | export const AddRow: Prevention = AddRowAbove | AddRowBelow; // 12 22 | 23 | export const AddCol: Prevention = AddColLeft | AddColRight; // 48 24 | 25 | export const Add: Prevention = AddRow | AddCol; // 60 26 | 27 | export const Delete: Prevention = DeleteRow | DeleteCol; // 3 28 | 29 | export const ReadOnly: Prevention = Update | Delete | Add | Move; // 30 | 31 | export const isPrevented = (prevention: Prevention | undefined, flag: Prevention) => { 32 | if (prevention === undefined) { 33 | return false; 34 | } 35 | return (prevention & flag) === flag; 36 | }; 37 | 38 | // Don't use this function in production 39 | export const debugOperations = (prevention: Prevention | undefined) => { 40 | const preventions: string[] = []; 41 | if (isPrevented(prevention, DeleteRow)) { 42 | preventions.push('DeleteRow'); 43 | } 44 | if (isPrevented(prevention, DeleteCol)) { 45 | preventions.push('DeleteCol'); 46 | } 47 | if (isPrevented(prevention, AddRowAbove)) { 48 | preventions.push('AddRowAbove'); 49 | } 50 | if (isPrevented(prevention, AddRowBelow)) { 51 | preventions.push('AddRowBelow'); 52 | } 53 | if (isPrevented(prevention, AddColLeft)) { 54 | preventions.push('AddColLeft'); 55 | } 56 | if (isPrevented(prevention, AddColRight)) { 57 | preventions.push('AddColRight'); 58 | } 59 | if (isPrevented(prevention, MoveFrom)) { 60 | preventions.push('MoveFrom'); 61 | } 62 | if (isPrevented(prevention, MoveTo)) { 63 | preventions.push('MoveTo'); 64 | } 65 | if (isPrevented(prevention, Write)) { 66 | preventions.push('Write'); 67 | } 68 | if (isPrevented(prevention, Style)) { 69 | preventions.push('Style'); 70 | } 71 | if (isPrevented(prevention, Resize)) { 72 | preventions.push('Resize'); 73 | } 74 | if (isPrevented(prevention, SetRenderer)) { 75 | preventions.push('SetRenderer'); 76 | } 77 | if (isPrevented(prevention, SetParser)) { 78 | preventions.push('SetParser'); 79 | } 80 | return preventions; 81 | }; 82 | -------------------------------------------------------------------------------- /packages/react-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gridsheet/react-core", 3 | "version": "1.4.0", 4 | "description": "Spreadsheet component for React", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "scripts": { 9 | "build": "rm -rf ./dist || true && vite build", 10 | "typecheck": "pnpm tsc --noEmit", 11 | "less": "lessc --clean-css ./styles/root.less ./styles/root.min.css", 12 | "generate-style": "node ./generate-style.js", 13 | "jest": "jest" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/walkframe/gridsheet.git" 18 | }, 19 | "keywords": [ 20 | "spreadsheet", 21 | "spread-sheet", 22 | "excel" 23 | ], 24 | "author": "righ", 25 | "license": "Apache-2.0", 26 | "files": [ 27 | "dist/", 28 | "package.json", 29 | "README.md", 30 | "LICENSE" 31 | ], 32 | "bugs": { 33 | "url": "https://github.com/walkframe/gridsheet/issues" 34 | }, 35 | "homepage": "https://docs.walkframe.com/gridsheet/react", 36 | "packageManager": "pnpm@10.6.5", 37 | "devDependencies": { 38 | "@types/jest": "^29.4.0", 39 | "@types/react": "^16.9.24", 40 | "@types/react-dom": "^16.9.24", 41 | "@vitejs/plugin-react": "^4.3.4", 42 | "jest": "^29.4.3", 43 | "jest-environment-jsdom": "^29.4.3", 44 | "less": "^4.1.3", 45 | "less-plugin-clean-css": "^1.5.1", 46 | "react": ">=16.9.0", 47 | "react-dom": ">=16.9.0", 48 | "react-is": "^16.13.1", 49 | "ts-jest": "^29.0.5", 50 | "ts-node": "^10.9.1", 51 | "typescript": "^5.8.2", 52 | "vite": "^6.2.2", 53 | "vite-plugin-dts": "^4.5.3" 54 | }, 55 | "peerDependencies": { 56 | "react": ">=16.9.0", 57 | "react-dom": ">=16.9.0", 58 | "dayjs": "^1.11.13" 59 | }, 60 | "dependencies": { 61 | "dayjs": "^1.11.13" 62 | }, 63 | "publishConfig": { 64 | "access": "public" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/react-core/renderers/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { WriterType } from '../types'; 3 | 4 | export const CheckboxRendererMixin = { 5 | bool(value: boolean, writer?: WriterType): any { 6 | return ( 7 | <input 8 | type="checkbox" 9 | checked={value} 10 | onChange={(e) => { 11 | writer && writer(e.currentTarget.checked.toString()); 12 | e.currentTarget.blur(); 13 | }} 14 | /> 15 | ); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/react-core/renderers/thousand_separator.ts: -------------------------------------------------------------------------------- 1 | export const ThousandSeparatorRendererMixin = { 2 | number(value: number): any { 3 | if (isNaN(value)) { 4 | return 'NaN'; 5 | } 6 | const [int, fraction] = String(value).split('.'); 7 | const result = int.replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,'); 8 | if (fraction == null) { 9 | return result; 10 | } 11 | return `${result}.${fraction}`; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/react-core/store/helpers.ts: -------------------------------------------------------------------------------- 1 | import { a2p, x2c, y2r } from '../lib/converters'; 2 | import { Table } from '../lib/table'; 3 | import { Address, PointType, StoreType } from '../types'; 4 | 5 | export const restrictPoints = (store: StoreType, table: Table) => { 6 | const { choosing, selectingZone, copyingZone } = store; 7 | let { y, x } = choosing; 8 | let { startY: y1, startX: x1, endY: y2, endX: x2 } = selectingZone; 9 | let { startY: y3, startX: x3, endY: y4, endX: x4 } = copyingZone; 10 | const [numRows, numCols] = [table.getNumRows(), table.getNumCols()]; 11 | if (y > numRows) { 12 | y = numRows; 13 | } 14 | if (x > numCols) { 15 | x = numCols; 16 | } 17 | if (y1 > numRows) { 18 | y1 = numRows; 19 | } 20 | if (y2 > numRows) { 21 | y2 = numRows; 22 | } 23 | if (x1 > numCols) { 24 | x1 = numCols; 25 | } 26 | if (x2 > numCols) { 27 | x2 = numCols; 28 | } 29 | if (y3 > numRows) { 30 | y3 = numRows; 31 | } 32 | if (y4 > numRows) { 33 | y4 = numRows; 34 | } 35 | if (x3 > numCols) { 36 | x3 = numCols; 37 | } 38 | if (x4 > numCols) { 39 | x4 = numCols; 40 | } 41 | return { 42 | choosing: { y, x } as PointType, 43 | selectingZone: { startY: y1, startX: x1, endY: y2, endX: x2 }, 44 | copyingZone: { startY: y3, startX: x3, endY: y4, endX: x4 }, 45 | }; 46 | }; 47 | 48 | export const shouldTracking = (operation: string) => { 49 | switch (operation) { 50 | case 'ADD_ROWS': 51 | return true; 52 | case 'ADD_COLS': 53 | return true; 54 | case 'DELETE_ROWS': 55 | return true; 56 | case 'DELETE_COLS': 57 | return true; 58 | case 'MOVE': 59 | return true; 60 | } 61 | return false; 62 | }; 63 | 64 | export const initSearchStatement = (table: Table, store: StoreType) => { 65 | const { searchQuery, searchCaseSensitive } = store; 66 | let { choosing } = store; 67 | if (!searchQuery) { 68 | return { matchingCells: [] }; 69 | } 70 | const matchingCells: Address[] = []; 71 | for (let y = 1; y <= table.bottom; y++) { 72 | for (let x = 1; x <= table.right; x++) { 73 | const v = table.stringify({ y, x }, undefined, true); 74 | const s = searchCaseSensitive ? v : v.toLowerCase(); 75 | const q = searchCaseSensitive ? searchQuery : searchQuery.toLowerCase(); 76 | if (s.indexOf(q) !== -1) { 77 | matchingCells.push(`${x2c(x)}${y2r(y)}`); 78 | } 79 | } 80 | } 81 | const matchingCellIndex = matchingCells.length === store.matchingCells.length ? store.matchingCellIndex : 0; 82 | if (matchingCells.length > 0) { 83 | const address = matchingCells[matchingCellIndex]; 84 | choosing = a2p(address); 85 | } 86 | return { matchingCells, searchQuery, matchingCellIndex, choosing }; 87 | }; 88 | -------------------------------------------------------------------------------- /packages/react-core/store/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StoreType } from '../types'; 3 | 4 | export type Dispatcher = React.Dispatch<{ 5 | type: number; 6 | value: any; 7 | }>; 8 | 9 | export const Context = React.createContext( 10 | {} as { 11 | store: StoreType; 12 | dispatch: Dispatcher; 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /packages/react-core/styles/cell.less: -------------------------------------------------------------------------------- 1 | .gs-cell { 2 | padding: 0; 3 | margin: 0; 4 | box-sizing: border-box; 5 | position: relative; 6 | font-size: 13px; 7 | letter-spacing: 1px; 8 | line-height: 24px; 9 | 10 | .gs-cell-inner-wrap { 11 | &.gs-selecting { 12 | background-color: rgba(0, 128, 255, 0.2); 13 | } 14 | } 15 | 16 | &.gs-copying { 17 | textarea:focus { 18 | outline: solid 1px #0077ff; 19 | } 20 | } 21 | &.gs-selecting { 22 | z-index: 1; 23 | .gs-cell-inner { 24 | background-color: rgba(0, 128, 255, 0.2); 25 | } 26 | .gs-cell-label { 27 | display: block; 28 | } 29 | } 30 | &.gs-choosing { 31 | margin-top: -1px; 32 | margin-left: -1px; 33 | z-index: 1; 34 | 35 | &.gs-editing { 36 | color: transparent; 37 | } 38 | .gs-cell-label { 39 | display: block; 40 | } 41 | } 42 | 43 | .gs-formula-error-triangle { 44 | position: absolute; 45 | top: 0; 46 | right: 0; 47 | border-top: 3px solid rgba(200, 0, 0, 0.9); 48 | border-right: 3px solid rgba(200, 0, 0, 0.9); 49 | border-bottom: 3px solid transparent; 50 | border-left: 3px solid transparent; 51 | z-index: 1; 52 | } 53 | .gs-cell-label { 54 | font-size: 8px; 55 | font-weight: normal; 56 | font-style: normal; 57 | font-family: math, monospace, serif; 58 | letter-spacing: 1px; 59 | line-height: 14px; 60 | 61 | position: absolute; 62 | top: 0; 63 | right: 0; 64 | background-color: rgba(0, 128, 255, 0.2); 65 | color: rgba(255, 255, 255, 0.6); 66 | padding: 0 2px; 67 | display: none; 68 | opacity: 0.7; 69 | } 70 | .gs-cell-inner-wrap { 71 | width: 100%; 72 | height: 100%; 73 | position: absolute; 74 | top: 0; 75 | left: 0; 76 | } 77 | .gs-cell-inner { 78 | width: 100%; 79 | height: 100%; 80 | overflow: hidden; 81 | display: flex; 82 | box-sizing: border-box; 83 | border: none !important; 84 | } 85 | 86 | .gs-cell-rendered { 87 | overflow: hidden; 88 | white-space: pre-wrap; 89 | cursor: auto; 90 | word-wrap: break-word; 91 | word-break: break-all; 92 | padding: 0 2px; 93 | & > * { 94 | position: relative; 95 | } 96 | & > .backface { 97 | z-index: 0; 98 | } 99 | } 100 | .gs-autofill-drag { 101 | background-color: rgb(0, 119, 255); 102 | position: absolute; 103 | width: 7px; 104 | height: 7px; 105 | bottom: 0; 106 | right: 0; 107 | margin-right: -3.5px; 108 | margin-bottom: -3.5px; 109 | cursor: crosshair; 110 | z-index: 1; 111 | } 112 | } 113 | 114 | .gs-th[data-x="1"] { 115 | .gs-th-inner-wrap { 116 | border-left: none; 117 | } 118 | } 119 | 120 | .gs-th[data-y="1"] { 121 | .gs-th-inner-wrap { 122 | border-top: none; 123 | } 124 | } 125 | 126 | .gs-cell[data-x="1"] { 127 | border-left: none; 128 | } 129 | 130 | .gs-cell[data-y="1"] { 131 | border-top: none; 132 | } 133 | -------------------------------------------------------------------------------- /packages/react-core/styles/contextmenu.less: -------------------------------------------------------------------------------- 1 | 2 | .gs-contextmenu-modal { 3 | width: 100%; 4 | height: 100vh; 5 | z-index: 3; 6 | } 7 | 8 | .gs-contextmenu { 9 | z-index: 3; 10 | position: fixed; 11 | background-color: #ffffff; 12 | padding: 5px 0; 13 | border-radius: 5px; 14 | box-shadow: rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, 15 | rgba(60, 64, 67, 0.15) 0px 1px 3px 1px; 16 | 17 | ul { 18 | min-width: 250px; 19 | color: #555555; 20 | margin: 0; 21 | padding: 0; 22 | } 23 | li { 24 | padding: 5px 10px; 25 | list-style-type: none; 26 | 27 | &.gs-enabled { 28 | cursor: pointer; 29 | &:hover { 30 | background-color: #eeeeee; 31 | } 32 | } 33 | &.gs-disabled { 34 | opacity: 0.5; 35 | cursor: not-allowed; 36 | } 37 | 38 | &.gs-menu-divider { 39 | background-color: #aaaaaa; 40 | margin: 10px 0; 41 | padding: 0; 42 | height: 1px; 43 | } 44 | 45 | display: flex; 46 | 47 | .gs-menu-name { 48 | flex: 1; 49 | font-size: 15px; 50 | letter-spacing: 1px; 51 | } 52 | 53 | .gs-menu-shortcut { 54 | font-size: 10px; 55 | line-height: 15px; 56 | color: #999999; 57 | width: 15px; 58 | 59 | &:before { 60 | content: "("; 61 | } 62 | &:after { 63 | content: ")"; 64 | } 65 | 66 | .gs-menu-underline { 67 | text-decoration: underline; 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/react-core/styles/embedder.ts: -------------------------------------------------------------------------------- 1 | import { CSS, LAST_MODIFIED } from './minified'; 2 | 3 | export const embedStyle = () => { 4 | if (typeof window === 'undefined') { 5 | return; 6 | } 7 | const exists = document.querySelector(`style.gs-styling[data-modified-at='${LAST_MODIFIED}']`); 8 | if (exists) { 9 | return; 10 | } 11 | const style = document.createElement('style'); 12 | document.head.appendChild(style); 13 | style.setAttribute('class', 'gs-styling'); 14 | style.setAttribute('data-modified-at', `${LAST_MODIFIED}`); 15 | style.innerText = CSS; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/react-core/styles/root.less: -------------------------------------------------------------------------------- 1 | .gs-root1 { 2 | display: inline-block; 3 | max-width: 100%; 4 | overflow: auto; 5 | font-family: 6 | "SF Pro Text", 7 | "SF Pro Icons", 8 | "Helvetica Neue", 9 | "Helvetica", 10 | Arial, 11 | Meiryo, 12 | sans-serif; 13 | 14 | .gs-main { 15 | flex: 1; 16 | max-width: 100%; 17 | overflow: hidden; 18 | position: relative; 19 | box-sizing: border-box; 20 | -webkit-box-sizing: border-box; 21 | -moz-box-sizing: border-box; 22 | } 23 | 24 | .gs-resizing { 25 | width: 100%; 26 | height: 100%; 27 | position: absolute; 28 | background-color: rgba(0, 127, 255, 0.08); 29 | top: 0; 30 | left: 0; 31 | z-index: 2; 32 | } 33 | .gs-line { 34 | position: relative; 35 | top: 0; 36 | left: 0; 37 | border: dotted 1px #0077ff; 38 | box-sizing: border-box; 39 | 40 | span { 41 | font-size: 10px; 42 | padding: 3px; 43 | background-color: #0077ff; 44 | color: #ffffff; 45 | margin: 0; 46 | position: absolute; 47 | top: 0; 48 | left: 0; 49 | } 50 | } 51 | } 52 | 53 | .gs-fixed { 54 | position: fixed; 55 | top: 0; 56 | left: 0; 57 | z-index: 1; 58 | } 59 | 60 | @import 'theme-light.less'; 61 | @import 'theme-dark.less'; 62 | @import 'cell.less'; 63 | @import 'contextmenu.less'; 64 | @import 'editor.less'; 65 | @import 'tabular.less'; 66 | @import 'search.less'; 67 | -------------------------------------------------------------------------------- /packages/react-core/styles/search.less: -------------------------------------------------------------------------------- 1 | .gs-search-bar { 2 | width: 100%; 3 | display: table; 4 | align-items: center; 5 | justify-content: center; 6 | border-top: solid 1px rgba(128, 128, 128, 0.3); 7 | border-left: solid 1px rgba(128, 128, 128, 0.3); 8 | border-right: solid 1px rgba(128, 128, 128, 0.3); 9 | box-sizing: border-box; 10 | background-color: rgba(200, 50, 0, 0.2); 11 | 12 | &.gs-search-found { 13 | background-color: rgba(0, 200, 100, 0.2); 14 | } 15 | 16 | .gs-search-bar-inner { 17 | vertical-align: middle; 18 | border-left: solid 1px rgba(128, 128, 128, 0.5); 19 | } 20 | 21 | .gs-search-bar-icon { 22 | border-left: solid 1px rgba(128, 128, 128, 0.3); 23 | display: table-cell; 24 | vertical-align: middle; 25 | width: 30px; 26 | } 27 | 28 | textarea { 29 | background-color: transparent; 30 | border: none; 31 | padding: 0 2px; 32 | box-sizing: border-box; 33 | outline: none; 34 | -webkit-box-shadow: none; 35 | -moz-box-shadow: none; 36 | box-shadow: none; 37 | font-size: 12px; 38 | font-family: monospace, Arial; 39 | height: 24px; 40 | line-height: 24px; 41 | min-height: 24px; 42 | letter-spacing: 1.0px; 43 | caret-color:rgba(128, 128, 128); 44 | white-space: pre-wrap; 45 | word-break: break-all; 46 | display: table-cell; 47 | vertical-align: middle; 48 | width: 100%; 49 | resize: none; 50 | } 51 | } 52 | 53 | .gs-search-progress { 54 | display: table-cell; 55 | color: #999999; 56 | font-size: 13px; 57 | width: 60px; 58 | vertical-align: middle; 59 | white-space: nowrap; 60 | text-align: center; 61 | } 62 | 63 | .gs-search-close { 64 | display: table-cell; 65 | cursor: pointer; 66 | vertical-align: middle; 67 | width: 24px; 68 | } 69 | 70 | .gs-search-casesensitive { 71 | display: table-cell; 72 | cursor: pointer; 73 | vertical-align: middle; 74 | width: 30px; 75 | 76 | span { 77 | font-size: 14px; 78 | padding: 0 3px; 79 | &.gs-search-casesensitive-on { 80 | color: #0077ff; 81 | background-color: rgba(200, 200, 255, 0.5); 82 | border-radius: 3px; 83 | } 84 | } 85 | 86 | 87 | } 88 | -------------------------------------------------------------------------------- /packages/react-core/styles/tabular.less: -------------------------------------------------------------------------------- 1 | 2 | .gs-adjuster { 3 | padding: 0; 4 | } 5 | 6 | .gs-tabular { 7 | overflow: auto; 8 | display: block; 9 | box-sizing: border-box; 10 | overscroll-behavior-x: contain; 11 | } 12 | 13 | .gs-tabular-inner { 14 | & > table { 15 | table-layout: fixed; 16 | border-collapse: collapse; 17 | } 18 | } 19 | 20 | .gs-th { 21 | z-index: 2; 22 | padding: 0; 23 | position: sticky; 24 | font-size: 13px; 25 | font-weight: normal; 26 | box-sizing: border-box; 27 | vertical-align: top; 28 | 29 | .gs-resizer { 30 | position: absolute; 31 | border-color: transparent; 32 | box-sizing: border-box; 33 | z-index: 2; 34 | &:hover { 35 | background-color: #0077ff; 36 | } 37 | &.gs-protected { 38 | display: none; 39 | } 40 | } 41 | } 42 | 43 | .gs-th-inner { 44 | height: 100%; 45 | box-sizing: border-box; 46 | vertical-align: middle; 47 | overflow: hidden; 48 | display: flex; 49 | align-items: center; 50 | justify-content: center; 51 | } 52 | 53 | .gs-th-inner-wrap { 54 | height: 100%; 55 | box-sizing: border-box; 56 | } 57 | 58 | .gs-th-top { 59 | top: 0; 60 | overflow: hidden; 61 | 62 | .gs-resizer { 63 | top: 0; 64 | right: 0; 65 | width: 3px; 66 | cursor: e-resize; 67 | 68 | &.gs-dragging { 69 | border-right-style: dotted; 70 | height: 1000000px !important; 71 | cursor: e-resize; 72 | } 73 | } 74 | } 75 | 76 | .gs-th-left { 77 | left: 0; 78 | overflow: hidden; 79 | min-width: 30px; 80 | 81 | .gs-resizer { 82 | left: 0; 83 | bottom: 0; 84 | height: 3px; 85 | cursor: n-resize; 86 | 87 | &.gs-dragging { 88 | border-bottom-style: dotted; 89 | width: 1000000px !important; 90 | cursor: n-resize; 91 | } 92 | } 93 | } 94 | 95 | .gs-th-top.gs-th-left { 96 | top: 0; 97 | left: 0; 98 | z-index: 3; 99 | } -------------------------------------------------------------------------------- /packages/react-core/styles/theme-dark.less: -------------------------------------------------------------------------------- 1 | @dark_text_color: #eeeeee; 2 | @dark_border_color: #5a5a5a; 3 | @dark_cell_color: #212121; 4 | @dark_bg_color: #3f3f3f; 5 | @dark_bg_color_lighter: #5a5a5a; 6 | @dark_header_color: #4f4f4f; 7 | @dark_header_text_color: #eeeeee; 8 | @dark_header_selecting_color: #606060; 9 | @dark_header_selecting_color_rev: #aaaaaa; 10 | @dark_header_choosing_color: #777777; 11 | @dark_formulabar_bg_color: #4f4f4f; 12 | @dark_formulabar_text_color: #bbbbbb; 13 | 14 | .gs-root1[data-mode="dark"] { 15 | background-color: @dark_bg_color_lighter; 16 | color: @dark_text_color; 17 | .gs-main { 18 | background-color: @dark_bg_color; 19 | border-right: solid 1px @dark_border_color; 20 | border-bottom: solid 1px @dark_border_color; 21 | } 22 | .gs-tabular { 23 | background-color: @dark_bg_color; 24 | } 25 | .gs-formula-bar { 26 | background-color: @dark_formulabar_bg_color; 27 | } 28 | .gs-formula-bar-editor-inner { 29 | color: @dark_formulabar_text_color; 30 | } 31 | .gs-cell { 32 | border-top: solid 1px @dark_border_color; 33 | border-left: solid 1px @dark_border_color; 34 | } 35 | .gs-adjuster { 36 | background-color: @dark_border_color; 37 | } 38 | .gs-tabular-inner { 39 | background-color: @dark_cell_color; 40 | } 41 | .gs-large-editor { 42 | textarea { 43 | color: @dark_text_color; 44 | caret-color: @dark_text_color; 45 | } 46 | } 47 | .gs-th { 48 | background-color: @dark_header_color; 49 | color: @dark_header_text_color; 50 | &.gs-selecting { 51 | background-color: @dark_header_selecting_color; 52 | } 53 | &.gs-choosing { 54 | background-color: @dark_header_choosing_color; 55 | } 56 | &.gs-th-selecting { 57 | background-color: @dark_header_selecting_color_rev; 58 | color: #444444; 59 | } 60 | } 61 | .gs-th-top { 62 | .gs-th-inner { 63 | border-top: solid 1px @dark_border_color; 64 | } 65 | .gs-th-inner-wrap { 66 | border-left: solid 1px @dark_border_color; 67 | } 68 | } 69 | .gs-th-left { 70 | .gs-th-inner { 71 | border-left: solid 1px @dark_border_color; 72 | } 73 | .gs-th-inner-wrap { 74 | border-top: solid 1px @dark_border_color; 75 | } 76 | } 77 | .gs-search-bar { 78 | textarea { 79 | color: @dark_text_color; 80 | caret-color: @dark_text_color; 81 | } 82 | } 83 | .gs-search-bar { 84 | color: rgba(255, 255, 255, 0.3); 85 | } 86 | } 87 | 88 | .gs-editor[data-mode="dark"] { 89 | &.gs-editing { 90 | textarea { 91 | caret-color: @dark_text_color; 92 | } 93 | .gs-editor-hl { 94 | background-color: @dark_cell_color; 95 | color: @dark_text_color; 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /packages/react-core/styles/theme-light.less: -------------------------------------------------------------------------------- 1 | @light_text_color: #000000; 2 | @light_text_color_selecting: #ffffff; 3 | @light_border_color: #dddddd; 4 | @light_cell_color: #f7f7f7; 5 | @light_bg_color: #e2e2e2; 6 | @light_header_color: #efefef; 7 | @light_header_text_color: #666666; 8 | @light_header_selecting_color: #d2d2d2; 9 | @light_header_selecting_color_rev: #555555; 10 | @light_header_choosing_color: #bbbbbb; 11 | @light_formulabar_bg_color: #efefef; 12 | @light_formulabar_text_color: #555555; 13 | 14 | .gs-root1[data-mode="light"] { 15 | background-color: @light_bg_color; 16 | color: @light_text_color; 17 | 18 | .gs-main { 19 | background-color: @light_bg_color; 20 | border-right: solid 1px @light_border_color; 21 | border-bottom: solid 1px @light_border_color; 22 | } 23 | .gs-tabular { 24 | background-color: @light_bg_color; 25 | } 26 | .gs-formula-bar { 27 | background-color: @light_formulabar_bg_color; 28 | } 29 | .gs-formula-bar-editor-inner { 30 | color: @light_formulabar_text_color; 31 | } 32 | .gs-cell { 33 | border-top: solid 1px @light_border_color; 34 | border-left: solid 1px @light_border_color; 35 | } 36 | .gs-adjuster { 37 | background-color: @light_border_color; 38 | } 39 | .gs-tabular-inner { 40 | background-color: @light_cell_color; 41 | } 42 | 43 | .gs-th { 44 | background-color: @light_header_color; 45 | color: @light_header_text_color; 46 | &.gs-selecting { 47 | background-color: @light_header_selecting_color; 48 | } 49 | &.gs-choosing { 50 | background-color: @light_header_choosing_color; 51 | } 52 | &.gs-th-selecting { 53 | background-color: @light_header_selecting_color_rev; 54 | color: @light_text_color_selecting; 55 | } 56 | } 57 | .gs-th-top { 58 | .gs-th-inner { 59 | border-top: solid 1px @light_border_color; 60 | } 61 | .gs-th-inner-wrap { 62 | border-left: solid 1px @light_border_color; 63 | } 64 | } 65 | .gs-th-left { 66 | .gs-th-inner { 67 | border-left: solid 1px @light_border_color; 68 | } 69 | .gs-th-inner-wrap { 70 | border-top: solid 1px @light_border_color; 71 | } 72 | } 73 | .gs-search-bar { 74 | color: rgba(0, 0, 0, 0.3); 75 | } 76 | } 77 | 78 | .gs-editor[data-mode="light"] { 79 | &.gs-editing { 80 | textarea { 81 | caret-color: @light_text_color; 82 | } 83 | .gs-editor-hl { 84 | background-color: @light_cell_color; 85 | color: @light_text_color; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/react-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "lib": ["DOM", "ESNext", "DOM.Iterable", "ScriptHost", "ES2016.Array.Include"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "outDir": "./dist/", 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "baseUrl": "./src/", 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "exclude": ["node_modules", "storybook", "dist", "e2e", "plugins"], 22 | "include": ["**/*.ts", "**/*.tsx"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-core/utils.ts: -------------------------------------------------------------------------------- 1 | export const setDefault = <K = PropertyKey, D = any>(target: any, key: K, defaultValue: D): D => { 2 | if (target[key] == null) { 3 | target[key] = defaultValue; 4 | } 5 | return target[key]; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/react-core/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig(({ mode }) => ({ 6 | plugins: [react(), dts({ insertTypesEntry: true })], 7 | build: { 8 | lib: { 9 | entry: "./index.ts", 10 | name: "GridSheet", 11 | formats: ["es"], 12 | fileName: (_, name) => `${name}.js`, 13 | }, 14 | outDir: 'dist', 15 | rollupOptions: { 16 | external: [/^react/, /^@?react-dom/], 17 | output: { 18 | preserveModules: true, 19 | preserveModulesRoot: process.cwd(), 20 | globals: { 21 | react: "React", 22 | "react-dom": "ReactDOM", 23 | }, 24 | }, 25 | }, 26 | sourcemap: mode === "development", 27 | minify: mode === "development" ? false : "esbuild", 28 | }, 29 | })); 30 | -------------------------------------------------------------------------------- /packages/react-right-menu/README.md: -------------------------------------------------------------------------------- 1 | ![unittest workflow](https://github.com/walkframe/gridsheet/actions/workflows/unittest.yaml/badge.svg?branch=master) 2 | ![e2e workflow](https://github.com/walkframe/gridsheet/actions/workflows/e2e.yaml/badge.svg?branch=master) 3 | 4 | ![gridsheet-right-menu](https://github.com/walkframe/gridsheet/raw/master/packages/react-right-menu/gridsheet-right-menu.png) 5 | 6 | ## Installation 7 | 8 | ```sh 9 | $ npm install @gridsheet/react-right-menu --save 10 | ``` 11 | 12 | ## Usage 13 | 14 | ```jsx 15 | import { GridSheet } from "@gridsheet/react-core"; 16 | import { RightMenu } from '@gridsheet/react-right-menu'; 17 | 18 | const Component => () => { 19 | return ( 20 | <RightMenu> 21 | <GridSheet 22 | initialCells={{ 23 | cells: { 24 | A1: { value: 1 }, 25 | B1: { value: 2 }, 26 | C1: { value: 3 }, 27 | A2: { value: 4 }, 28 | B2: { value: 5 }, 29 | C2: { value: 6 }, 30 | } 31 | }} 32 | /> 33 | </RightMenu> 34 | ); 35 | }; 36 | ``` -------------------------------------------------------------------------------- /packages/react-right-menu/gridsheet-right-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walkframe/gridsheet/8092970c4390039cbca3b6c8eb2f5c860118b7d1/packages/react-right-menu/gridsheet-right-menu.png -------------------------------------------------------------------------------- /packages/react-right-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { RightMenu } from './right-menu'; 2 | -------------------------------------------------------------------------------- /packages/react-right-menu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gridsheet/react-right-menu", 3 | "version": "0.0.1-alpha.4", 4 | "description": "GridSheet right menu for React", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "scripts": { 9 | "build": "rm -rf ./dist || true && vite build", 10 | "typecheck": "pnpm tsc --noEmit", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "righ", 14 | "license": "Apache-2.0", 15 | "packageManager": "pnpm@10.6.5", 16 | "devDependencies": { 17 | "@gridsheet/react-core": "workspace:^", 18 | "@types/react": ">=16.9.0", 19 | "@types/react-dom": ">=16.9.0", 20 | "typescript": "^5.8.2", 21 | "vite": "^6.2.2", 22 | "vite-plugin-dts": "^4.5.3" 23 | }, 24 | "peerDependencies": { 25 | "@gridsheet/react-core": "^1.4.0-alpha.0", 26 | "react": ">=16.9.0", 27 | "react-dom": ">=16.9.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/react-right-menu/style.ts: -------------------------------------------------------------------------------- 1 | export const style = ` 2 | 3 | .gs-rightmenu-wrapper { 4 | display: flex; 5 | } 6 | 7 | .gs-rightmenu-main { 8 | z-index: 3; 9 | transition: width 0.5s; 10 | font-size: 12px; 11 | min-width: 130px; 12 | } 13 | 14 | .gs-rightmenu-main[data-mode="light"] { 15 | background-color: rgb(240, 240, 240); 16 | color: rgb(0, 0, 0); 17 | } 18 | 19 | .gs-rightmenu-main[data-mode="dark"] { 20 | background-color: rgb(50, 50, 50); 21 | color: rgb(255, 255, 255); 22 | } 23 | 24 | .gs-rightmenu-main[data-mode="dark"] input { 25 | color: rgb(255, 255, 255); 26 | } 27 | 28 | .gs-rightmenu-main[data-mode="dark"] input { 29 | background-color: transparent; 30 | } 31 | 32 | .gs-rightmenu-items { 33 | min-width: 100px; 34 | box-sizing: border-box; 35 | } 36 | 37 | .gs-rightmenu-item { 38 | margin-bottom: 20px; 39 | } 40 | 41 | .gs-rightmenu-header { 42 | padding: 5px; 43 | border-bottom: double 3px rgba(128, 128, 128, 0.3); 44 | } 45 | 46 | .gs-rightmenu-block { 47 | padding: 5px; 48 | } 49 | 50 | .gs-rightmenu-row { 51 | display: flex; 52 | margin: 5px 0; 53 | height: 24px; 54 | } 55 | 56 | .gs-rightmenu-row input[disabled] { 57 | opacity: 0.3; 58 | } 59 | 60 | .gs-rightmenu-col { 61 | width: 65px; 62 | } 63 | 64 | .gs-rightmenu-input { 65 | border: solid 1px #d9d9d9; 66 | box-sizing: border-box; 67 | 68 | list-style: none; 69 | position: relative; 70 | display: inline-block; 71 | width: 50px; 72 | 73 | } 74 | .gs-right-menu-btn { 75 | font-size: 14px; 76 | line-height: 24px; 77 | padding: 0 5px; 78 | } 79 | .gs-right-menu-btn { 80 | outline: none; 81 | position: relative; 82 | display: inline-flex; 83 | align-items: center; 84 | justify-content: center; 85 | white-space: nowrap; 86 | text-align: center; 87 | background-color: #4096ff; 88 | border: none; 89 | cursor: pointer; 90 | user-select: none; 91 | touch-action: manipulation; 92 | color: #fff; 93 | box-sizing: border-box; 94 | } 95 | 96 | .gs-right-menu-btn > span { 97 | display: inline-flex; 98 | } 99 | `; 100 | -------------------------------------------------------------------------------- /packages/react-right-menu/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "lib": ["DOM", "ESNext", "DOM.Iterable", "ScriptHost", "ES2016.Array.Include"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "outDir": "./dist/", 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "baseUrl": "./src/", 19 | "forceConsistentCasingInFileNames": true, 20 | "paths": { 21 | "@gridsheet/react-core": ["../react-core"], 22 | "@gridsheet/react-right-menu": ["./"] 23 | } 24 | }, 25 | "exclude": ["node_modules", "storybook", "dist", "e2e"], 26 | "include": ["**/*.ts", "**/*.tsx"] 27 | } 28 | -------------------------------------------------------------------------------- /packages/react-right-menu/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig(({ mode }) => ({ 6 | plugins: [react(), dts({ insertTypesEntry: true })], 7 | build: { 8 | lib: { 9 | entry: "./index.ts", 10 | name: "RightMenu", 11 | formats: ["es"], 12 | fileName: (_, name) => `${name}.js`, 13 | }, 14 | rollupOptions: { 15 | external: [/^react/, /^@?react-dom/, "@gridsheet/react-core"], 16 | output: { 17 | preserveModules: true, 18 | preserveModulesRoot: process.cwd(), 19 | globals: { 20 | react: "React", 21 | "react-dom": "ReactDOM", 22 | }, 23 | }, 24 | }, 25 | sourcemap: mode === "development", 26 | minify: mode === "development" ? false : "esbuild", 27 | }, 28 | })); 29 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | import path from 'path'; 3 | import { mergeConfig } from 'vite'; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | 6 | const config: StorybookConfig = { 7 | stories: ['../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 8 | addons: [ 9 | //"@storybook/addon-links", 10 | '@storybook/addon-essentials', 11 | '@storybook/addon-onboarding', 12 | '@storybook/addon-interactions', 13 | '@chromatic-com/storybook', 14 | //"@storybook/experimental-addon-test" 15 | ], 16 | framework: { 17 | name: '@storybook/react-vite', 18 | options: {}, 19 | }, 20 | docs: { 21 | autodocs: 'tag', 22 | }, 23 | async viteFinal(config) { 24 | config = mergeConfig(config, { 25 | plugins: [tsconfigPaths()], 26 | resolve: { 27 | conditions: ['development', 'import', 'require'], 28 | }, 29 | }); 30 | config.resolve = config.resolve || {}; 31 | config.resolve.alias = { 32 | ...(config.resolve.alias || {}), 33 | '@gridsheet/react-core': path.resolve(__dirname, '../../react-core/index.ts'), 34 | '@gridsheet/react-right-menu': path.resolve(__dirname, '../../react-right-menu/index.ts'), 35 | }; 36 | return config; 37 | }, 38 | }; 39 | export default config; 40 | -------------------------------------------------------------------------------- /packages/storybook/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react').Preview } */ 2 | const preview = { 3 | parameters: { 4 | controls: { 5 | matchers: { 6 | color: /(background|color)$/i, 7 | date: /Date$/i, 8 | }, 9 | }, 10 | }, 11 | }; 12 | 13 | export default preview; -------------------------------------------------------------------------------- /packages/storybook/.storybook/vitest.setup.js: -------------------------------------------------------------------------------- 1 | import { beforeAll } from 'vitest'; 2 | import { setProjectAnnotations } from '@storybook/react'; 3 | import * as projectAnnotations from './preview'; 4 | 5 | // This is an important step to apply the right configuration when testing your stories. 6 | // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations 7 | const project = setProjectAnnotations([projectAnnotations]); 8 | 9 | beforeAll(project.beforeAll); -------------------------------------------------------------------------------- /packages/storybook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gridsheet/storybook", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "storybook dev -p 5233", 9 | "build": "storybook build" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "packageManager": "pnpm@10.6.5", 15 | "devDependencies": { 16 | "@chromatic-com/storybook": "^3", 17 | "@gridsheet/react-core": "workspace:^", 18 | "@gridsheet/react-right-menu": "workspace:^", 19 | "@storybook/addon-essentials": "^8.6.7", 20 | "@storybook/addon-interactions": "^8.6.7", 21 | "@storybook/addon-onboarding": "^8.6.7", 22 | "@storybook/blocks": "^8.6.7", 23 | "@storybook/experimental-addon-test": "^8.6.7", 24 | "@storybook/react": "^8.6.7", 25 | "@storybook/react-vite": "^8.6.7", 26 | "@storybook/test": "^8.6.7", 27 | "@types/react": "^19.0.12", 28 | "@vitest/browser": "^3.0.9", 29 | "@vitest/coverage-v8": "^3.0.9", 30 | "playwright": "^1.51.1", 31 | "prop-types": "^15.8.1", 32 | "storybook": "^8.6.7", 33 | "tsc": "^2.0.4", 34 | "vite": "^6.2.2", 35 | "vite-tsconfig-paths": "^5.1.4", 36 | "vitest": "^3.0.9" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/labeler.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Basic', 6 | }; 7 | 8 | export const Labeler = () => { 9 | const [width, setWidth] = React.useState(500); 10 | React.useEffect(() => { 11 | setInterval(() => { 12 | setWidth(width - 50); 13 | }, 10000); 14 | }); 15 | 16 | return ( 17 | <> 18 | <GridSheet 19 | initialCells={constructInitialCells({ 20 | cells: { 21 | A: { labeler: 'hiragana' }, 22 | B: { labeler: 'hiragana' }, 23 | C: { labeler: 'hiragana' }, 24 | D: { labeler: 'hiragana' }, 25 | E: { labeler: 'hiragana' }, 26 | 1: { labeler: 'katakana' }, 27 | 2: { labeler: 'katakana' }, 28 | 3: { labeler: 'katakana' }, 29 | 4: { labeler: 'katakana' }, 30 | 5: { labeler: 'katakana' }, 31 | A1: { value: '=SUM($B$1:B2)' }, 32 | }, 33 | ensured: { numRows: 100, numCols: 100 }, 34 | })} 35 | options={{ 36 | labelers: { 37 | hiragana: (n) => 'あいうえおかきくけこ'.slice(n - 1, n), 38 | katakana: (n) => 'アイウエオカキクケコ'.slice(n - 1, n), 39 | }, 40 | sheetWidth: width, 41 | }} 42 | /> 43 | </> 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/parser.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet, Parser, Renderer } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Basic', 6 | }; 7 | 8 | class ListRenderer extends Renderer { 9 | array(value: any[]) { 10 | return ( 11 | <ul> 12 | {value.map((v, i) => ( 13 | <li key={i}>{v}</li> 14 | ))} 15 | </ul> 16 | ); 17 | } 18 | stringify({ value }: { value: any[] }): string { 19 | if (Array.isArray(value)) { 20 | return value.join('\n'); 21 | } 22 | return value == null ? '' : String(value); 23 | } 24 | } 25 | 26 | const ListParserMixin = { 27 | functions: [(value: string) => value.split(/\n/g)], 28 | }; 29 | 30 | export const ParseAsList = () => { 31 | return ( 32 | <> 33 | <GridSheet 34 | initialCells={constructInitialCells({ 35 | matrices: { 36 | A1: [ 37 | [ 38 | [1, 2, 3], 39 | [4, 5, 6], 40 | [7, 8, 9], 41 | ], 42 | [ 43 | [10, 11, 12], 44 | [13, 14, 15], 45 | [16, 17, 18], 46 | ], 47 | [ 48 | [19, 20, 21], 49 | [22, 23, 24], 50 | [25, 26, 27], 51 | ], 52 | ], 53 | }, 54 | cells: { 55 | default: { 56 | height: 100, 57 | renderer: 'list', 58 | parser: 'list', 59 | }, 60 | }, 61 | ensured: { numRows: 30, numCols: 20 }, 62 | })} 63 | options={{ 64 | renderers: { 65 | list: new ListRenderer(), 66 | }, 67 | parsers: { 68 | list: new Parser({ mixins: [ListParserMixin] }), 69 | }, 70 | }} 71 | /> 72 | </> 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/renderer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | constructInitialCells, 4 | GridSheet, 5 | Renderer, 6 | RendererMixinType, 7 | CheckboxRendererMixin, 8 | PointType, 9 | p2a, 10 | } from '@gridsheet/react-core'; 11 | 12 | export default { 13 | title: 'Basic', 14 | }; 15 | 16 | const kanjiMap: { [s: string]: string } = { 17 | '0': '〇', 18 | '1': '一', 19 | '2': '二', 20 | '3': '三', 21 | '4': '四', 22 | '5': '五', 23 | '6': '六', 24 | '7': '七', 25 | '8': '八', 26 | '9': '九', 27 | '.': '.', 28 | }; 29 | 30 | const NullMixin: RendererMixinType = { 31 | null(value: null, writer?: any, position?: PointType) { 32 | return <span style={{ opacity: 0.3 }}>{p2a(position!)}</span>; 33 | }, 34 | undefined(value: undefined, writer?: any, position?: PointType) { 35 | return <span style={{ opacity: 0.3 }}>{p2a(position!)}</span>; 36 | }, 37 | }; 38 | 39 | const KanjiRendererMixin: RendererMixinType = { 40 | string(value: string): string { 41 | return value; 42 | }, 43 | number(value: number): string { 44 | const minus = value < 0; 45 | 46 | let kanji = ''; 47 | let [int, fraction] = String(Math.abs(value)).split('.'); 48 | for (let i = 0; i < int.length; i++) { 49 | const j = int.length - i; 50 | if (j % 3 === 0 && i !== 0) { 51 | kanji += ','; 52 | } 53 | kanji += kanjiMap[int[i]]; 54 | } 55 | if (fraction == null) { 56 | return minus ? `-${kanji}` : kanji; 57 | } 58 | kanji += '.'; 59 | for (let i = 0; i < fraction.length; i++) { 60 | kanji += kanjiMap[fraction[i]]; 61 | } 62 | return minus ? `-${kanji}` : kanji; 63 | }, 64 | }; 65 | 66 | export const RenderToKanji = () => { 67 | return ( 68 | <> 69 | <GridSheet 70 | initialCells={constructInitialCells({ 71 | matrices: { 72 | A1: [[true, false]], 73 | B3: [[100], [200, 300], [400, 500, 600], [800, 900, 1000, 1100]], 74 | }, 75 | cells: { 76 | default: { 77 | renderer: 'kanji', 78 | }, 79 | B10: { 80 | value: '=B6+10000', 81 | } 82 | }, 83 | ensured: { numRows: 30, numCols: 20 }, 84 | })} 85 | options={{ 86 | renderers: { 87 | kanji: new Renderer({ 88 | mixins: [KanjiRendererMixin, CheckboxRendererMixin, NullMixin], 89 | }), 90 | }, 91 | }} 92 | /> 93 | </> 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/resize.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Basic', 6 | }; 7 | 8 | export const ResizeSheets = () => { 9 | return ( 10 | <> 11 | <table style={{ width: '100%', tableLayout: 'fixed' }}> 12 | <tbody> 13 | <tr> 14 | <td> 15 | {' '} 16 | <GridSheet 17 | style={{ maxWidth: '100%', maxHeight: '150px' }} 18 | initialCells={constructInitialCells({ 19 | matrices: { 20 | A1: [ 21 | ['resizable', 'both', '!'], 22 | [1, 2, 3], 23 | [undefined, 5, 6], 24 | ], 25 | }, 26 | cells: { A3: { value: 'four' } }, 27 | })} 28 | options={{ 29 | mode: 'dark', 30 | sheetResize: 'both', 31 | sheetHeight: 500, 32 | sheetWidth: 500, 33 | }} 34 | /> 35 | </td> 36 | <td> 37 | {' '} 38 | <GridSheet 39 | style={{ maxWidth: '100%', maxHeight: '150px' }} 40 | initialCells={constructInitialCells({ 41 | matrices: { 42 | A1: [ 43 | ['resizable', 'vertically', '!'], 44 | [1, 2, 3], 45 | [4, undefined, 6], 46 | ], 47 | }, 48 | cells: { B3: { value: 'five' } }, 49 | })} 50 | options={{ 51 | sheetResize: 'vertical', 52 | sheetHeight: 500, 53 | sheetWidth: 500, 54 | }} 55 | /> 56 | </td> 57 | </tr> 58 | <tr> 59 | <td> 60 | {' '} 61 | <GridSheet 62 | style={{ maxWidth: '100%', maxHeight: '150px' }} 63 | initialCells={constructInitialCells({ 64 | matrices: { 65 | A1: [ 66 | ['resizable', 'horizontally', '!'], 67 | [1, 2, 3], 68 | [4, 5, undefined], 69 | ], 70 | }, 71 | cells: { C3: { value: 'six' } }, 72 | })} 73 | options={{ 74 | sheetResize: 'horizontal', 75 | sheetHeight: 500, 76 | sheetWidth: 500, 77 | showFormulaBar: false, 78 | }} 79 | /> 80 | </td> 81 | <td> 82 | {' '} 83 | <GridSheet 84 | style={{ maxWidth: '100%', maxHeight: '150px' }} 85 | initialCells={constructInitialCells({ 86 | matrices: { 87 | A1: [ 88 | ['not', 'resizable', '!!!'], 89 | [1, 2, 3], 90 | [4, 5, 6], 91 | ], 92 | }, 93 | cells: { A3: { value: 'four' } }, 94 | })} 95 | options={{ 96 | sheetResize: 'none', 97 | sheetHeight: 500, 98 | sheetWidth: 500, 99 | showFormulaBar: false, 100 | }} 101 | /> 102 | </td> 103 | </tr> 104 | </tbody> 105 | </table> 106 | </> 107 | ); 108 | }; 109 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/sheets.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { constructInitialCells, GridSheet, SheetProvider } from '@gridsheet/react-core'; 4 | 5 | type Props = { 6 | numRows: number; 7 | numCols: number; 8 | defaultWidth: number; 9 | }; 10 | 11 | const Sheets = ({ numRows, numCols, defaultWidth }: Props) => { 12 | const [sheet1, setSheet1] = React.useState('Sheet1'); 13 | const [sheet2, setSheet2] = React.useState('Sheet2'); 14 | const [sheet3, setSheet3] = React.useState('Sheet 3'); 15 | return ( 16 | <SheetProvider> 17 | <GridSheet 18 | sheetName={sheet1} 19 | initialCells={constructInitialCells({ 20 | cells: { 21 | default: { width: defaultWidth }, 22 | A1: { 23 | value: '=Sheet2!A1+100', 24 | }, 25 | A2: { 26 | value: '=SUM(Sheet2!B2:B4)', 27 | }, 28 | A3: { 29 | value: "='Sheet 3'!A1 + 1000", 30 | }, 31 | B1: { 32 | value: "=SUM('Invalid Sheet'!B2:B3)", 33 | }, 34 | C1: { 35 | value: 333, 36 | }, 37 | C2: { 38 | value: '=C1+100', 39 | }, 40 | }, 41 | ensured: { numRows, numCols }, 42 | })} 43 | /> 44 | <br /> 45 | <input id="input1" value={sheet1} onChange={(e) => setSheet1(e.target.value)} /> 46 | <hr /> 47 | <GridSheet 48 | sheetName={sheet2} 49 | initialCells={constructInitialCells({ 50 | cells: { 51 | A1: { value: 50 }, 52 | B1: { value: 999 }, 53 | B2: { value: 1200 }, 54 | B3: { value: 30 }, 55 | }, 56 | ensured: { numRows, numCols }, 57 | })} 58 | options={{ 59 | sheetResize: 'both', 60 | }} 61 | /> 62 | <br /> 63 | <input id="input2" value={sheet2} onChange={(e) => setSheet2(e.target.value)} /> 64 | <hr /> 65 | 66 | <GridSheet 67 | sheetName={sheet3} 68 | initialCells={constructInitialCells({ 69 | cells: { 70 | A1: { value: 555 }, 71 | }, 72 | ensured: { numRows, numCols }, 73 | })} 74 | /> 75 | <br /> 76 | <input id="input3" value={sheet3} onChange={(e) => setSheet3(e.target.value)} /> 77 | </SheetProvider> 78 | ); 79 | }; 80 | 81 | export const MultipleSheet: StoryObj<typeof Sheets> = { 82 | args: { numRows: 5, numCols: 3, defaultWidth: 100 }, 83 | }; 84 | 85 | export default { 86 | title: 'Basic', 87 | component: Sheets, 88 | }; 89 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/showAddress.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 4 | 5 | type Props = { 6 | showAddress: boolean; 7 | }; 8 | 9 | const Sheet = ({ showAddress }: Props) => { 10 | return ( 11 | <> 12 | <GridSheet 13 | initialCells={constructInitialCells({ 14 | ensured: { numRows: 100, numCols: 100 }, 15 | })} 16 | options={{ showAddress }} 17 | /> 18 | </> 19 | ); 20 | }; 21 | 22 | export const ShowAddress: StoryObj<typeof Sheet> = { 23 | args: { showAddress: true }, 24 | }; 25 | 26 | export default { 27 | title: 'Basic', 28 | component: Sheet, 29 | }; 30 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/size.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | 4 | import { CellsByAddressType, constructInitialCells, GridSheet, TimeDelta } from '@gridsheet/react-core'; 5 | 6 | type Props = { 7 | numRows: number; 8 | numCols: number; 9 | defaultWidth: number; 10 | initialCells?: CellsByAddressType; 11 | }; 12 | 13 | const Sheet = ({ numRows, numCols, defaultWidth, initialCells }: Props) => { 14 | return ( 15 | <> 16 | <GridSheet 17 | options={{ 18 | // mode: "dark", 19 | headerHeight: 50, 20 | headerWidth: 150, 21 | labelers: { 22 | raw: (n) => String(n), 23 | }, 24 | sheetResize: 'both', 25 | editingOnEnter: true, 26 | onInit(table) { 27 | console.log('onInit', table); 28 | }, 29 | }} 30 | initialCells={constructInitialCells({ 31 | cells: { 32 | default: { width: defaultWidth, labeler: 'raw' }, 33 | A1: { 34 | value: 'A1', 35 | }, 36 | B1: { 37 | value: 'B1', 38 | }, 39 | B2: { 40 | value: 2, 41 | }, 42 | C3: { 43 | value: 3, 44 | }, 45 | A4: { 46 | value: new Date('2022-03-05T12:34:56+09:00'), 47 | }, 48 | B4: { 49 | value: TimeDelta.create(11, 11, 11), 50 | }, 51 | C4: { 52 | value: '=A4+B4', 53 | }, 54 | A5: { 55 | value: '=A4-13/24', 56 | }, 57 | ...initialCells, 58 | }, 59 | ensured: { numRows, numCols }, 60 | })} 61 | /> 62 | </> 63 | ); 64 | }; 65 | 66 | export const Small: StoryObj<typeof Sheet> = { 67 | args: { numRows: 5, numCols: 3, defaultWidth: 100 }, 68 | }; 69 | 70 | export const Large: StoryObj<typeof Sheet> = { 71 | args: { 72 | numRows: 1000, 73 | numCols: 100, 74 | defaultWidth: 50, 75 | initialCells: { A500: { value: 'aa' }, A1000: { value: 'aaa' }, CV1000: { value: 'aaaa' } }, 76 | }, 77 | }; 78 | 79 | export default { 80 | title: 'Basic', 81 | component: Sheet, 82 | }; 83 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/style.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Basic', 6 | }; 7 | 8 | export const Style = () => { 9 | return ( 10 | <div style={{ transform: 'translate(50px, 50px)' }}> 11 | <GridSheet 12 | initialCells={constructInitialCells({ 13 | matrices: { 14 | A1: [ 15 | ['a', 'b', 'c', 'd', 'e'], 16 | ['aa', 'bb', 'cc', 'dd', 'ee'], 17 | ['aaa', 'bbb', 'ccc', 'ddd', 'eee'], 18 | ['aaaa', 'bbbb', 'cccc', 'dddd', 'eeee'], 19 | ['aaaaa', 'bbbbb', 'ccccc', 'ddddd', 'eeeee'], 20 | ], 21 | }, 22 | cells: { 23 | default: { 24 | value: 'DEFAULT', 25 | style: { 26 | fontStyle: 'italic', 27 | backgroundColor: '#000', 28 | color: '#777', 29 | }, 30 | }, 31 | A: {}, 32 | B: { 33 | style: { 34 | backgroundColor: '#eeeeee', 35 | fontSize: 30, 36 | color: '#fff', 37 | fontFamily: 'fantasy', 38 | letterSpacing: 20, 39 | lineHeight: '60px', 40 | }, 41 | width: 200, 42 | }, 43 | C: { 44 | style: { backgroundColor: '#dddddd', textDecoration: 'underline' }, 45 | }, 46 | D: { 47 | style: { backgroundColor: '#cccccc' }, 48 | }, 49 | E: { 50 | style: { backgroundColor: '#bbbbbb' }, 51 | }, 52 | 1: { 53 | style: { color: '#333' }, 54 | }, 55 | 2: { 56 | style: { color: '#F00' }, 57 | height: 100, 58 | alignItems: 'center', 59 | justifyContent: 'center', 60 | }, 61 | 3: { 62 | style: { color: '#0C0' }, 63 | height: 250, 64 | }, 65 | 4: { 66 | style: { color: '#00F' }, 67 | }, 68 | 'B5:D6': { 69 | style: { backgroundColor: 'green' }, 70 | }, 71 | '20:22': { 72 | style: { backgroundColor: 'blue' }, 73 | }, 74 | E2: { 75 | style: { 76 | borderTop: 'dashed 3px orange', 77 | borderLeft: 'dashed 3px orange', 78 | borderBottom: 'dashed 3px orange', 79 | borderRight: 'dashed 3px orange', 80 | }, 81 | }, 82 | E5: { 83 | style: { 84 | backgroundColor: '#F0F', 85 | }, 86 | }, 87 | F6: { 88 | value: 'F6', 89 | }, 90 | }, 91 | ensured: { numRows: 50, numCols: 10 }, 92 | })} 93 | options={{}} 94 | /> 95 | </div> 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /packages/storybook/stories/basic/theme.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { constructInitialCells, GridSheet, ModeType } from '@gridsheet/react-core'; 4 | 5 | type Props = { 6 | mode: ModeType; 7 | }; 8 | 9 | const Sheet = ({ mode }: Props) => { 10 | return ( 11 | <> 12 | <GridSheet 13 | initialCells={constructInitialCells({ 14 | ensured: { numRows: 10, numCols: 10 }, 15 | })} 16 | options={{ mode }} 17 | /> 18 | </> 19 | ); 20 | }; 21 | 22 | export const Dark: StoryObj<typeof Sheet> = { 23 | args: { mode: 'dark' }, 24 | }; 25 | 26 | export default { 27 | title: 'Basic', 28 | component: Sheet, 29 | }; 30 | -------------------------------------------------------------------------------- /packages/storybook/stories/demo/list.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | GridSheet, 4 | Renderer, 5 | Parser, 6 | MatrixType, 7 | constructInitialCells, 8 | aa2oa, 9 | RendererMixinType, 10 | ParserMixinType, 11 | CheckboxRendererMixin, 12 | } from '@gridsheet/react-core'; 13 | 14 | export default { 15 | title: 'Demo', 16 | }; 17 | 18 | const ListRendererMixin: RendererMixinType = { 19 | array(value: any[]) { 20 | return ( 21 | <ul> 22 | {value.map((v, i) => ( 23 | <li key={i}>{v}</li> 24 | ))} 25 | </ul> 26 | ); 27 | }, 28 | stringify({ value }: { value: any[] }): string { 29 | if (Array.isArray(value)) { 30 | return value.join('\n'); 31 | } 32 | return String(value) || ''; 33 | }, 34 | }; 35 | 36 | const ListParserMixin: ParserMixinType = { 37 | functions: [(value: string) => value.split(/\n/g)], 38 | }; 39 | 40 | const initialData: MatrixType = [ 41 | [true, 'Ichiro', 'Baseball player', ['Curry Rice', 'Baseball']], 42 | [true, 'Jiro', 'Ramen shop owner', ['Ramen']], 43 | [true, 'Saburo', 'Singer', ['Song']], 44 | [true, 'Shiro', 'Sword master', ['Christianity']], 45 | [true, 'Goro', 'Solo proprietorship', ['Eating alone']], 46 | ]; 47 | 48 | export function SecondDemo() { 49 | const [tsv, setTsv] = React.useState(''); 50 | 51 | return ( 52 | <div className="example-app"> 53 | <h1>Sloppy data</h1> 54 | <GridSheet 55 | initialCells={constructInitialCells({ 56 | matrices: { A1: initialData }, 57 | cells: { 58 | default: { height: 100 }, 59 | A: { width: 50, renderer: 'checkbox', alignItems: 'center', justifyContent: 'center' }, 60 | C: { width: 200 }, 61 | D: { width: 400, renderer: 'list', parser: 'list' }, 62 | }, 63 | })} 64 | options={{ 65 | headerHeight: 30, 66 | sheetWidth: 600, 67 | sheetHeight: 600, 68 | 69 | renderers: { 70 | checkbox: new Renderer({ mixins: [CheckboxRendererMixin] }), 71 | list: new Renderer({ mixins: [ListRendererMixin] }), 72 | }, 73 | parsers: { 74 | list: new Parser({ mixins: [ListParserMixin] }), 75 | }, 76 | onSave: (table) => { 77 | const matrix = table.getMatrixFlatten({}); 78 | const filtered = matrix.filter((row) => row[0]).map((row) => row.slice(1)); 79 | setTsv(filtered.map((cols) => cols.join('\t')).join('\n')); 80 | }, 81 | onChange: (table) => { 82 | const matrix = table.getMatrixFlatten({}); 83 | if (matrix != null) { 84 | console.log('data onchange:', matrix && aa2oa(matrix, ['name', 'occupation', 'memo'])); 85 | } 86 | const diff = table.getObjectFlatten({ 87 | filter: (cell) => !!cell?.changedAt && cell.changedAt > table.lastChangedAt!, 88 | }); 89 | console.log('onchange diff:', diff); 90 | }, 91 | }} 92 | /> 93 | <p>TSV: (Ctrl+s to update)</p> 94 | <textarea 95 | placeholder="Inactive rows will be ommited" 96 | value={tsv} 97 | style={{ width: '100%', minHeight: '200px' }} 98 | ></textarea> 99 | </div> 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /packages/storybook/stories/events/onchange.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GridSheet, constructInitialCells, createTableRef, HistoryType } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Table operations', 6 | }; 7 | 8 | export const SheetOnChange = () => { 9 | const [diff, setDiff] = React.useState<Record<string, any>>({}); 10 | const [evaluates, setEvaluates] = React.useState<boolean>(true); 11 | const [histories, setHistories] = React.useState<HistoryType[]>([]); 12 | const tableRef = createTableRef(); 13 | const table = tableRef.current?.table; 14 | React.useEffect(() => { 15 | if (table == null) { 16 | return; 17 | } 18 | setDiff( 19 | table.getObjectFlatten({ 20 | evaluates, 21 | filter: (cell) => !!cell?.changedAt && cell.changedAt > table.lastChangedAt!, 22 | }), 23 | ); 24 | }, [table, evaluates]); 25 | 26 | return ( 27 | <> 28 | <div style={{ display: 'flex' }}> 29 | <div style={{ flex: 1 }}> 30 | <GridSheet 31 | tableRef={tableRef} 32 | initialCells={constructInitialCells({ 33 | matrices: { 34 | A1: [ 35 | [1, 2, 3, 4, 5], 36 | [6, 7, 8, 9, 10], 37 | ], 38 | }, 39 | cells: { 40 | default: { 41 | width: 50, 42 | }, 43 | E: { 44 | style: { backgroundColor: '#ddf' }, 45 | }, 46 | }, 47 | ensured: { 48 | numRows: 20, 49 | numCols: 10, 50 | }, 51 | })} 52 | options={{ 53 | sheetWidth: 300, 54 | sheetHeight: 300, 55 | onChange: (table, positions) => { 56 | const histories = table.getHistories(); 57 | setHistories(histories); 58 | const h = histories[histories.length - 1]; 59 | if (h?.operation === 'UPDATE') { 60 | console.log('histories', table.getAddressesByIds(h.diffAfter)); 61 | } 62 | console.log('matrix', table.getMatrixFlatten({ evaluates })); 63 | }, 64 | }} 65 | /> 66 | <div>Diff:</div> 67 | <textarea 68 | id="changes" 69 | style={{ width: '300px', height: '100px' }} 70 | value={JSON.stringify(diff, null, 2)} 71 | ></textarea> 72 | Changes:{' '} 73 | <input id="evaluates" type="checkbox" checked={evaluates} onChange={() => setEvaluates(!evaluates)} /> 74 | </div> 75 | <ul className="histories"> 76 | {histories.map((history, i) => ( 77 | <li 78 | key={i} 79 | style={{ 80 | display: 'flex', 81 | lineHeight: '20px', 82 | borderBottom: 'solid 1px #777', 83 | marginBottom: '10px', 84 | backgroundColor: table?.getHistoryIndex() === i ? '#fdd' : 'transparent', 85 | }} 86 | > 87 | <div style={{ color: '#09a' }}>[{history.operation}]</div> 88 | <pre style={{ margin: 0 }}> 89 | {(() => { 90 | if (history.operation === 'UPDATE') { 91 | return JSON.stringify(table?.getAddressesByIds(history.diffAfter)); 92 | } 93 | })()} 94 | </pre> 95 | </li> 96 | ))} 97 | </ul> 98 | </div> 99 | </> 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /packages/storybook/stories/events/replace.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GridSheet, constructInitialCells, createTableRef, Table } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Table operations', 6 | }; 7 | 8 | export const Replace = () => { 9 | const tableRef = createTableRef(); 10 | 11 | return ( 12 | <> 13 | <GridSheet 14 | tableRef={tableRef} 15 | initialCells={constructInitialCells({ 16 | cells: {}, 17 | ensured: { 18 | numRows: 50, 19 | numCols: 50, 20 | }, 21 | })} 22 | /> 23 | <br /> 24 | <button 25 | onClick={() => { 26 | const dispatch = tableRef.current?.dispatch; 27 | if (dispatch == null) { 28 | return; 29 | } 30 | 31 | const table = new Table({}); 32 | table.initialize( 33 | constructInitialCells({ 34 | cells: {}, 35 | matrices: { 36 | A1: [ 37 | [1, 2, 3], 38 | [6, 7, 8], 39 | ], 40 | }, 41 | }), 42 | ); 43 | dispatch(table); 44 | }} 45 | > 46 | Replace 47 | </button> 48 | </> 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /packages/storybook/stories/events/update.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { GridSheet, constructInitialCells, createTableRef, HistoryType } from '@gridsheet/react-core'; 4 | 5 | export default { 6 | title: 'Table operations', 7 | }; 8 | 9 | export const Update = () => { 10 | const tableRef = createTableRef(); 11 | const [json, setJson] = React.useState(` 12 | { 13 | "A5": {"value": "test"} 14 | }`); 15 | 16 | const update = () => { 17 | if (tableRef.current) { 18 | const { table, dispatch } = tableRef.current; 19 | const diff = JSON.parse(json); 20 | console.log(diff); 21 | dispatch(table.update({ diff })); 22 | } 23 | }; 24 | 25 | return ( 26 | <> 27 | <textarea 28 | placeholder="Input JSON" 29 | rows={10} 30 | cols={100} 31 | value={json} 32 | onChange={(e) => setJson(e.target.value)} 33 | ></textarea> 34 | <br /> 35 | <button onClick={update}>Update!</button> 36 | <GridSheet 37 | tableRef={tableRef} 38 | initialCells={constructInitialCells({ 39 | cells: {}, 40 | ensured: { 41 | numRows: 10, 42 | numCols: 10, 43 | }, 44 | })} 45 | /> 46 | </> 47 | ); 48 | }; 49 | 50 | export const AddRowsAndUpdate = () => { 51 | const tableRef = createTableRef(); 52 | 53 | const add = () => { 54 | if (tableRef.current) { 55 | const { table, dispatch } = tableRef.current; 56 | dispatch( 57 | table.addRowsAndUpdate({ 58 | y: 5, 59 | numRows: 1, 60 | baseY: 5, 61 | diff: { 62 | C5: { value: 'added', style: { textDecoration: 'underline' } }, 63 | }, 64 | }), 65 | ); 66 | } 67 | }; 68 | 69 | return ( 70 | <> 71 | <br /> 72 | <button onClick={add}>Add!</button> 73 | <GridSheet 74 | tableRef={tableRef} 75 | initialCells={constructInitialCells({ 76 | cells: { 77 | B: { style: { color: '#F00' } }, 78 | B7: { value: 'test1' }, 79 | C8: { value: 'test2' }, 80 | 5: { style: { backgroundColor: '#077', color: '#fff' } }, 81 | }, 82 | ensured: { 83 | numRows: 10, 84 | numCols: 10, 85 | }, 86 | })} 87 | /> 88 | </> 89 | ); 90 | }; 91 | 92 | export const AddColsAndUpdate = () => { 93 | const tableRef = createTableRef(); 94 | 95 | const add = () => { 96 | if (tableRef.current) { 97 | const { table, dispatch } = tableRef.current; 98 | dispatch( 99 | table.addColsAndUpdate({ 100 | x: 4, 101 | numCols: 1, 102 | baseX: 4, 103 | diff: { 104 | D4: { value: 'added' }, 105 | }, 106 | }), 107 | ); 108 | } 109 | }; 110 | 111 | return ( 112 | <> 113 | <br /> 114 | <button onClick={add}>Add!</button> 115 | <GridSheet 116 | tableRef={tableRef} 117 | initialCells={constructInitialCells({ 118 | cells: { 119 | B: { style: { color: '#F00' } }, 120 | B7: { value: 'test1' }, 121 | C8: { value: 'test2' }, 122 | D: { style: { backgroundColor: '#077', color: '#fff' } }, 123 | }, 124 | ensured: { 125 | numRows: 10, 126 | numCols: 10, 127 | }, 128 | })} 129 | /> 130 | </> 131 | ); 132 | }; 133 | -------------------------------------------------------------------------------- /packages/storybook/stories/events/write.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { GridSheet, constructInitialCells, createTableRef, HistoryType } from '@gridsheet/react-core'; 4 | 5 | type Props = { 6 | x: number; 7 | y: number; 8 | value: string; 9 | }; 10 | 11 | const Sheet = ({ x, y, value }: Props) => { 12 | const tableRef = createTableRef(); 13 | React.useEffect(() => { 14 | if (tableRef?.current == null) { 15 | return; 16 | } 17 | const { table, dispatch } = tableRef.current; 18 | dispatch(table.write({ point: { y, x }, value })); 19 | }, [x, y, value, tableRef]); 20 | 21 | return ( 22 | <> 23 | <GridSheet 24 | tableRef={tableRef} 25 | initialCells={constructInitialCells({ 26 | cells: {}, 27 | ensured: { 28 | numRows: 50, 29 | numCols: 50, 30 | }, 31 | })} 32 | options={{ 33 | onKeyUp: (e, points) => { 34 | console.log('onKeyUp', e.currentTarget.value, points.pointing); 35 | }, 36 | onInit: (table) => { 37 | console.debug('onInit', table); 38 | }, 39 | }} 40 | /> 41 | </> 42 | ); 43 | }; 44 | 45 | export const Write: StoryObj<typeof Sheet> = { 46 | args: { x: 1, y: 1, value: 'something' }, 47 | }; 48 | 49 | export default { 50 | title: 'Table operations', 51 | component: Sheet, 52 | }; 53 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/col.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Formula', 6 | }; 7 | 8 | export const Col = () => { 9 | return ( 10 | <> 11 | <GridSheet 12 | initialCells={constructInitialCells({ 13 | cells: { 14 | A1: { value: '=COL()' }, 15 | A2: { value: '=COL()' }, 16 | B1: { value: '=COL()' }, 17 | C5: { value: '=COL()' }, 18 | C6: { value: '=COL(A3)' }, 19 | }, 20 | ensured: { numRows: 100, numCols: 100 }, 21 | })} 22 | options={{}} 23 | /> 24 | </> 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/custom.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BaseFunction } from '@gridsheet/react-core'; 3 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 4 | 5 | export default { 6 | title: 'Formula', 7 | }; 8 | 9 | class HopeFunction extends BaseFunction { 10 | main(text: string) { 11 | return `😸${text}😸`; 12 | } 13 | } 14 | 15 | class TestFunction extends BaseFunction { 16 | main() { 17 | return 'てすとだよ'; 18 | } 19 | } 20 | 21 | export const CustomFunction = () => { 22 | return ( 23 | <> 24 | <GridSheet 25 | initialCells={constructInitialCells({ 26 | cells: { 27 | default: { width: 200 }, 28 | B2: { value: '=HOPE("WORLD PEACE") & "!"' }, 29 | A3: { value: '=test()' }, 30 | }, 31 | ensured: { 32 | numRows: 10, 33 | numCols: 10, 34 | }, 35 | })} 36 | additionalFunctions={{ 37 | hope: HopeFunction, 38 | test: TestFunction, 39 | }} 40 | /> 41 | </> 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/disabled.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Formula', 6 | }; 7 | 8 | export const Disabled = () => { 9 | return ( 10 | <> 11 | <GridSheet 12 | initialCells={constructInitialCells({ 13 | cells: { 14 | A: { labeler: 'disabled', width: 150}, 15 | A1: { value: '=1+1', disableFormula: true }, 16 | B1: { value: '=1+1' }, 17 | A2: { value: "'quote", disableFormula: true }, 18 | B2: { value: "'quote" }, 19 | A3: { value: "'0123", disableFormula: true }, 20 | B3: { value: "'0123" }, 21 | A4: { value: '0123', disableFormula: true }, 22 | B4: { value: '0123' }, 23 | A5: { value: 123, disableFormula: true }, 24 | B5: { value: 123 }, 25 | }, 26 | ensured: { numRows: 5, numCols: 5 }, 27 | })} 28 | options={{ 29 | labelers: { 30 | disabled: (value: string) => { 31 | return 'disabled formula'; 32 | } 33 | } 34 | }} 35 | /> 36 | </> 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/lookup.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet, prevention } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Formula', 6 | }; 7 | 8 | export const LookUp = () => { 9 | return ( 10 | <> 11 | <h1>HLOOKUP</h1> 12 | <GridSheet 13 | initialCells={constructInitialCells({ 14 | cells: { 15 | 1: { style: { backgroundColor: '#ddd' } }, 16 | '2:3': { style: {} }, 17 | 'A:E': { width: 50 }, 18 | 'A4:C4': { 19 | prevention: prevention.Write, 20 | style: { 21 | backgroundColor: '#ddd', 22 | borderTop: 'solid 1px black', 23 | borderLeft: 'solid 1px black', 24 | borderRight: 'solid 1px black', 25 | borderBottom: 'double 3px black', 26 | fontWeight: 'bold', 27 | }, 28 | }, 29 | 'A5:C9': { 30 | style: { 31 | borderTop: 'solid 1px black', 32 | borderBottom: 'solid 1px black', 33 | borderLeft: 'solid 1px black', 34 | borderRight: 'solid 1px black', 35 | }, 36 | }, 37 | }, 38 | ensured: { numRows: 10, numCols: 10 }, 39 | matrices: { 40 | A1: [ 41 | [0, '=A1+60', '=B1+10', '=C1+10', '=D1+10', '=E1+5', '', '', '', ''], 42 | ['E', 'D', 'C', 'B', 'A', 'S', '', '', '', ''], 43 | ['', '', '', '', '', '', '', '', '', ''], 44 | ['Name', 'Point', 'Rank', '', '', '', '', '', '', ''], 45 | ['apple', 50, '=HLOOKUP(B5, $A$1:$F$2, 2, true)', '', '', '', '', '', '', ''], 46 | ['orange', 82, '=HLOOKUP(B6, A1:F2, 2, true)', '', '', '', '', '', '', ''], 47 | ['grape', 75, '=HLOOKUP(B7, A1:F2, 2, true)', '', '', '', '', '', '', ''], 48 | ['melon', 98, '=HLOOKUP(B8, A1:F2, 2, true)', '', '', '', '', '', '', ''], 49 | ['banana', 65, '=HLOOKUP(B9, A1:F2, 2, true)', '', '', '', '', '', '', ''], 50 | ], 51 | }, 52 | })} 53 | options={{}} 54 | /> 55 | <h1>VLOOKUP</h1> 56 | <GridSheet 57 | initialCells={constructInitialCells({ 58 | cells: { 59 | A: { width: 30 }, 60 | C: { width: 40 }, 61 | D: { width: 50, style: { textAlign: 'right' } }, 62 | E: { width: 130 }, 63 | }, 64 | matrices: { 65 | A1: [ 66 | [0, '子🐭'], 67 | [1, '丑🐮'], 68 | [2, '寅🐯'], 69 | [3, '卯🐰'], 70 | [4, '辰🐲'], 71 | [5, '巳🐍'], 72 | [6, '午🐴'], 73 | [7, '未🐑'], 74 | [8, '申🐵'], 75 | [9, '酉🐔'], 76 | [10, '戌🐶'], 77 | [11, '亥🐗'], 78 | ], 79 | D1: [ 80 | [2022, '年(西暦)の干支:', `=VLOOKUP(MOD(D1 - 4, 12), $A$1:$B$12, 2, false)`], 81 | [2021, '年(西暦)の干支:', `=VLOOKUP(MOD(D2 - 4, 12), $A$1:$B$12, 2, false)`], 82 | [2020, '年(西暦)の干支:', `=VLOOKUP(MOD(D3 - 4, 12), $A$1:$B$12, 2, false)`], 83 | [2019, '年(西暦)の干支:', `=VLOOKUP(MOD(D4 - 4, 12), $A$1:$B$12, 2, false)`], 84 | [2018, '年(西暦)の干支:', `=VLOOKUP(MOD(D5 - 4, 12), $A$1:$B$12, 2, false)`], 85 | ], 86 | }, 87 | })} 88 | /> 89 | </> 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/no_formula_bar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Formula', 6 | }; 7 | 8 | export const NoFormulaBar = () => { 9 | return ( 10 | <> 11 | <GridSheet 12 | initialCells={constructInitialCells({ 13 | matrices: {}, 14 | cells: { 15 | default: { 16 | width: 50, 17 | }, 18 | }, 19 | ensured: { numRows: 10, numCols: 10 }, 20 | })} 21 | options={{ 22 | sheetHeight: 600, 23 | showFormulaBar: false, 24 | }} 25 | /> 26 | </> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/row.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Formula', 6 | }; 7 | 8 | export const Row = () => { 9 | return ( 10 | <> 11 | <GridSheet 12 | initialCells={constructInitialCells({ 13 | cells: { 14 | A1: { value: '=ROW()' }, 15 | A2: { value: '=ROW()' }, 16 | B1: { value: '=ROW()' }, 17 | C5: { value: '=ROW()' }, 18 | C6: { value: '=ROW(A3)' }, 19 | }, 20 | ensured: { numRows: 100, numCols: 100 }, 21 | })} 22 | options={{}} 23 | /> 24 | </> 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/storybook/stories/formula/simple.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { constructInitialCells, GridSheet } from '@gridsheet/react-core'; 3 | 4 | export default { 5 | title: 'Formula', 6 | }; 7 | 8 | export const SimpleCalculation = () => { 9 | return ( 10 | <> 11 | <GridSheet 12 | initialCells={constructInitialCells({ 13 | matrices: { 14 | A1: [ 15 | ["'=100 + 5", "'=A2 - 60", "'=B2 * A2"], 16 | ['=100 + 5', '=A2-60', '=B2 * A2'], 17 | ], 18 | A4: [ 19 | ["'=100 / 5", "'=A5 ^ 3", "'=B5 * -4"], 20 | ['=100 / 5', '=A5 ^ 3', '=B5 * -4'], 21 | ], 22 | A7: [ 23 | ["'=(10 + 4) * 5", "'=A8 - 14 / 2", "'=(A8 - 14) / 2"], 24 | ['=(10 + 4) * 5', '=A8 - 14 / 2', '=(A8 - 14) / 2'], 25 | ], 26 | A10: [ 27 | [`'=500 * 10 ^ 12 & "円"`, `'=A11 & "ほしい!"`, `'="とても" & B11`], 28 | [`=500 * 10 ^ 12 & "円"`, `=A11 & "ほしい!"`, `="とても" & B11`], 29 | ], 30 | A13: [ 31 | [`'=100 = 100`, `'=100 = 200`, `'=100 <> 100`, `'=100 <> 200`], 32 | [`=100 = 100`, `=100 = 200`, `=100 <> 100`, `=100 <> 200`], 33 | ], 34 | A16: [ 35 | [`'=100 > 99`, `'=100 > 101`, `'=100 >= 100`, `'=100 >= 101`], 36 | [`=100 > 99`, `=100 > 101`, `=100 >= 100`, `=100 >= 101`], 37 | ], 38 | A19: [ 39 | [`'=100 < 99`, `'=100 < 101`, `'=100 <= 100`, `'=100 <= 99`], 40 | [`=100 < 99`, `=100 < 101`, `=100 <= 100`, `=100 <= 99`], 41 | ], 42 | A22: [ 43 | [`'=MOD(8, 3)`, `'=MOD(8, 2)`, `'=MOD(8, 10)`, `'=MOD(-8, 3)`, `'=MOD(8, -3)`], 44 | [`=MOD(8, 3)`, `=MOD(8, 2)`, `=MOD(8, 10)`, `=MOD(-8, 3)`, `=MOD(8, -3)`], 45 | ], 46 | }, 47 | cells: { 48 | default: { 49 | width: 250, 50 | }, 51 | }, 52 | ensured: { numRows: 100, numCols: 100 }, 53 | })} 54 | options={{ 55 | sheetHeight: 600, 56 | }} 57 | /> 58 | </> 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /packages/storybook/stories/plugin/menu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { CellsByAddressType, constructInitialCells, GridSheet } from '@gridsheet/react-core'; 4 | 5 | import { RightMenu as RightMenuComponent } from '@gridsheet/react-right-menu'; 6 | 7 | type Props = { 8 | numRows: number; 9 | numCols: number; 10 | defaultWidth: number; 11 | initialCells?: CellsByAddressType; 12 | }; 13 | 14 | const Sheet = ({ numRows, numCols, defaultWidth, initialCells }: Props) => { 15 | return ( 16 | <div> 17 | <RightMenuComponent> 18 | <GridSheet 19 | options={{ 20 | mode: 'light', 21 | headerHeight: 50, 22 | headerWidth: 100, 23 | labelers: { 24 | raw: (n) => String(n), 25 | }, 26 | sheetResize: 'both', 27 | editingOnEnter: true, 28 | }} 29 | initialCells={constructInitialCells({ 30 | ensured: { numRows: 5, numCols: 5 }, 31 | })} 32 | /> 33 | </RightMenuComponent> 34 | <br /> 35 | 36 | <RightMenuComponent> 37 | <GridSheet 38 | options={{ 39 | mode: 'dark', 40 | headerWidth: 100, 41 | labelers: { 42 | raw: (n) => String(n), 43 | }, 44 | sheetResize: 'both', 45 | editingOnEnter: true, 46 | }} 47 | initialCells={constructInitialCells({ 48 | cells: { 49 | default: { width: defaultWidth, labeler: 'raw' }, 50 | A1: { 51 | value: 'A1', 52 | }, 53 | B1: { 54 | value: 'B1', 55 | }, 56 | B2: { 57 | value: 2, 58 | }, 59 | C3: { 60 | value: 3, 61 | }, 62 | ...initialCells, 63 | }, 64 | ensured: { numRows, numCols }, 65 | })} 66 | /> 67 | </RightMenuComponent> 68 | <br /> 69 | </div> 70 | ); 71 | }; 72 | 73 | export const RightMenu: StoryObj<typeof Sheet> = { 74 | args: { numRows: 100, numCols: 100, defaultWidth: 50 }, 75 | }; 76 | 77 | export default { 78 | title: 'Plugin', 79 | component: Sheet, 80 | }; 81 | -------------------------------------------------------------------------------- /packages/storybook/stories/protection/protection.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { constructInitialCells, GridSheet, prevention } from '@gridsheet/react-core'; 4 | 5 | type Props = { 6 | numRows: number; 7 | numCols: number; 8 | defaultWidth: number; 9 | }; 10 | 11 | const Sheet = ({ numRows, numCols, defaultWidth }: Props) => { 12 | return ( 13 | <> 14 | <GridSheet 15 | options={{ 16 | headerHeight: 50, 17 | headerWidth: 150, 18 | }} 19 | initialCells={constructInitialCells({ 20 | cells: { 21 | default: { width: defaultWidth }, 22 | 4: { 23 | prevention: prevention.DeleteRow, 24 | }, 25 | 1: { 26 | prevention: prevention.Resize, 27 | style: { backgroundColor: '#eeeeee' }, 28 | }, 29 | 'A:B': { 30 | prevention: prevention.AddCol | prevention.DeleteCol, 31 | style: { backgroundColor: '#dddddd' }, 32 | }, 33 | A: { 34 | prevention: prevention.Resize, 35 | style: { backgroundColor: '#eeeeee' }, 36 | }, 37 | C: { 38 | style: { backgroundColor: '#ffffff' }, 39 | }, 40 | B2: { 41 | value: 'READONLY', 42 | prevention: prevention.ReadOnly, 43 | style: { backgroundColor: '#aaaaaa' }, 44 | }, 45 | }, 46 | ensured: { numRows, numCols }, 47 | })} 48 | /> 49 | </> 50 | ); 51 | }; 52 | 53 | export const Prevention: StoryObj<typeof Sheet> = { 54 | args: { numRows: 50, numCols: 20, defaultWidth: 50 }, 55 | }; 56 | 57 | export default { 58 | title: 'Basic', 59 | component: Sheet, 60 | }; 61 | -------------------------------------------------------------------------------- /packages/storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "lib": ["DOM", "ES2015", "DOM.Iterable", "ScriptHost", "ES2016.Array.Include"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "outDir": "./dist/", 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "baseUrl": "./src/", 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "exclude": ["node_modules", "storybook", "dist", "e2e"], 22 | "include": ["**/*.ts", "**/*.tsx"], 23 | "paths": { 24 | "@gridsheet/react-core": ["../react-core"], 25 | "@gridsheet/react-right-menu": ["../react-right-menu"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/storybook/vitest.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { defineConfig } from 'vitest/config'; 5 | 6 | import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin'; 7 | 8 | const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | // More info at: https://storybook.js.org/docs/writing-tests/test-addon 11 | export default defineConfig({ 12 | test: { 13 | workspace: [ 14 | { 15 | extends: true, 16 | plugins: [ 17 | // The plugin will run tests for the stories defined in your Storybook config 18 | // See options at: https://storybook.js.org/docs/writing-tests/test-addon#storybooktest 19 | storybookTest({ configDir: path.join(dirname, '.storybook') }), 20 | ], 21 | test: { 22 | name: 'storybook', 23 | browser: { 24 | enabled: true, 25 | headless: true, 26 | name: 'chromium', 27 | provider: 'playwright', 28 | }, 29 | setupFiles: ['.storybook/vitest.setup.js'], 30 | }, 31 | }, 32 | ], 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "lib": ["DOM", "ESNext", "DOM.Iterable", "ScriptHost", "ES2016.Array.Include"], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "sourceMap": true, 12 | "outDir": "./dist/", 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true, 18 | "baseUrl": "./src/", 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "exclude": ["node_modules", "storybook", "dist", "e2e"], 22 | "include": ["**/*.ts", "**/*.tsx"] 23 | } 24 | --------------------------------------------------------------------------------