├── .yarnrc.yml ├── style ├── index.js ├── index.css ├── icons │ ├── mdi-page-layout-header.svg │ ├── mdi-table-column-plus-after.svg │ ├── mdi-table-row-plus-after.svg │ ├── mdi-format-list-bulleted-type.svg │ ├── mdi-table-column-width.svg │ ├── mdi-format-list-numbered.svg │ ├── mdi-table-column-remove.svg │ ├── mdi-table-row-remove.svg │ ├── mdi-snowflake.svg │ ├── mdi-snowflake-off.svg │ └── LICENSE └── base.css ├── setup.py ├── .binder ├── postBuild └── environment.yml ├── src ├── typings.d.ts ├── icons.ts ├── statusbar.tsx ├── documentwidget.ts ├── searchprovider.ts ├── index.ts └── widget.ts ├── screenshots ├── launcher.png ├── setosa-demo.gif ├── formula-support.gif └── freeze-support.gif ├── .prettierignore ├── install.json ├── .github └── workflows │ ├── enforce-label.yml │ ├── check-release.yml │ ├── binder-on-pr.yml │ ├── publish.yml │ ├── prep-release.yml │ ├── publish-release.yml │ ├── build.yml │ └── codeql-analysis.yml ├── .copier-answers.yml ├── tsconfig.json ├── jupyterlab_spreadsheet_editor └── __init__.py ├── LICENSE ├── .gitignore ├── schema └── plugin.json ├── CHANGELOG.md ├── pyproject.toml ├── RELEASE.md ├── README.md └── package.json /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | -------------------------------------------------------------------------------- /.binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | python -m pip install . 5 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const script: string; 3 | export default script; 4 | } 5 | -------------------------------------------------------------------------------- /screenshots/launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/HEAD/screenshots/launcher.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | jupyterlab_spreadsheet_editor 7 | -------------------------------------------------------------------------------- /screenshots/setosa-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/HEAD/screenshots/setosa-demo.gif -------------------------------------------------------------------------------- /screenshots/formula-support.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/HEAD/screenshots/formula-support.gif -------------------------------------------------------------------------------- /screenshots/freeze-support.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/HEAD/screenshots/freeze-support.gif -------------------------------------------------------------------------------- /.binder/environment.yml: -------------------------------------------------------------------------------- 1 | name: jupyterlab-spreadsheet-editor 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.11 6 | - jupyterlab >=4,<5.0.0a0 7 | - nodejs=20 8 | - pip 9 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab-spreadsheet-editor", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab-spreadsheet-editor" 5 | } 6 | -------------------------------------------------------------------------------- /style/icons/mdi-page-layout-header.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /style/icons/mdi-table-column-plus-after.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /style/icons/mdi-table-row-plus-after.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /style/icons/mdi-format-list-bulleted-type.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /style/icons/mdi-table-column-width.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /style/icons/mdi-format-list-numbered.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /style/icons/mdi-table-column-remove.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /style/icons/mdi-table-row-remove.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v4.2.4 3 | _src_path: https://github.com/jupyterlab/extension-template 4 | author_email: '' 5 | author_name: Michał Krassowski 6 | has_binder: false 7 | has_settings: true 8 | kind: frontend 9 | labextension_name: spreadsheet-editor 10 | project_short_description: JupyterLab spreadsheet (csv/tsv) editor 11 | python_name: jupyterlab_spreadsheet_editor 12 | repository: https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor.git 13 | test: false 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018" 21 | }, 22 | "include": ["src/*"] 23 | } 24 | -------------------------------------------------------------------------------- /jupyterlab_spreadsheet_editor/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import __version__ 3 | except ImportError: 4 | # Fallback when using the package in dev mode without installing 5 | # in editable mode with pip. It is highly recommended to install 6 | # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs 7 | import warnings 8 | warnings.warn("Importing 'jupyterlab_spreadsheet_editor' outside a proper installation.") 9 | __version__ = "dev" 10 | 11 | 12 | def _jupyter_labextension_paths(): 13 | return [{ 14 | "src": "labextension", 15 | "dest": "spreadsheet-editor" 16 | }] 17 | -------------------------------------------------------------------------------- /style/icons/mdi-snowflake.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /style/icons/mdi-snowflake-off.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | branches: ["*"] 7 | 8 | jobs: 9 | check_release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Base Setup 15 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 16 | - name: Check Release 17 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 18 | with: 19 | 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Upload Distributions 23 | uses: actions/upload-artifact@v3 24 | with: 25 | name: jupyterlab_spreadsheet_editor-releaser-dist-${{ github.run_number }} 26 | path: .jupyter_releaser_checkout/dist 27 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) fcollonval 2 | # Distributed under the terms of the Modified BSD License. 3 | # Reference https://mybinder.readthedocs.io/en/latest/howto/gh-actions-badges.html 4 | name: Binder Badge 5 | on: 6 | pull_request_target: 7 | types: [opened] 8 | 9 | jobs: 10 | binder: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: comment on PR with Binder link 14 | uses: actions/github-script@v1 15 | with: 16 | github-token: ${{secrets.GITHUB_TOKEN}} 17 | script: | 18 | var PR_HEAD_USERREPO = process.env.PR_HEAD_USERREPO; 19 | var PR_HEAD_REF = process.env.PR_HEAD_REF; 20 | github.issues.createComment({ 21 | issue_number: context.issue.number, 22 | owner: context.repo.owner, 23 | repo: context.repo.repo, 24 | body: `[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/${PR_HEAD_USERREPO}/${PR_HEAD_REF}?urlpath=lab) :point_left: Launch a binder notebook on branch _${PR_HEAD_USERREPO}/${PR_HEAD_REF}_` 25 | }) 26 | env: 27 | PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} 28 | PR_HEAD_USERREPO: ${{ github.event.pull_request.head.repo.full_name }} 29 | 30 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | import removeRowSvg from '../style/icons/mdi-table-row-remove.svg'; 4 | import addColumnSvg from '../style/icons/mdi-table-column-plus-after.svg'; 5 | import removeColumnSvg from '../style/icons/mdi-table-column-remove.svg'; 6 | import freezeColumnSvg from '../style/icons/mdi-snowflake.svg'; 7 | import unfreezeColumnSvg from '../style/icons/mdi-snowflake-off.svg'; 8 | import addRowSvg from '../style/icons/mdi-table-row-plus-after.svg'; 9 | 10 | export const freezeColumnIcon = new LabIcon({ 11 | name: 'spreadsheet:freeze-columns', 12 | svgstr: freezeColumnSvg 13 | }); 14 | 15 | export const unfreezeColumnIcon = new LabIcon({ 16 | name: 'spreadsheet:unfreeze-columns', 17 | svgstr: unfreezeColumnSvg 18 | }); 19 | 20 | export const removeColumnIcon = new LabIcon({ 21 | name: 'spreadsheet:remove-column', 22 | svgstr: removeColumnSvg 23 | }); 24 | 25 | export const addColumnIcon = new LabIcon({ 26 | name: 'spreadsheet:add-column', 27 | svgstr: addColumnSvg 28 | }); 29 | 30 | export const removeRowIcon = new LabIcon({ 31 | name: 'spreadsheet:remove-row', 32 | svgstr: removeRowSvg 33 | }); 34 | 35 | export const addRowIcon = new LabIcon({ 36 | name: 'spreadsheet:add-row', 37 | svgstr: addRowSvg 38 | }); 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Install node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '14.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - name: Install Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip build twine jupyterlab 25 | - name: Build the federated extension in a separated build environment 26 | run: | 27 | python -m build 28 | - name: Publish the Python package 29 | env: 30 | TWINE_USERNAME: __token__ 31 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 32 | run: | 33 | twine upload dist/* 34 | - name: Build the source extension 35 | run: | 36 | jlpm 37 | jlpm build 38 | - name: Publish the NPM package 39 | run: | 40 | echo $PRE_RELEASE 41 | if [[ $PRE_RELEASE == "true" ]]; then export TAG="next"; else export TAG="latest"; fi 42 | npm publish --tag ${TAG} --access public 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | PRE_RELEASE: ${{ github.event.release.prerelease }} 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | since: 16 | description: "Use PRs with activity since this date or git reference" 17 | required: false 18 | since_last_stable: 19 | description: "Use PRs with activity since the last stable git tag" 20 | required: false 21 | type: boolean 22 | jobs: 23 | prep_release: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 27 | 28 | - name: Prep Release 29 | id: prep-release 30 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 31 | with: 32 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 33 | version_spec: ${{ github.event.inputs.version_spec }} 34 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 35 | branch: ${{ github.event.inputs.branch }} 36 | since: ${{ github.event.inputs.since }} 37 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 38 | 39 | - name: "** Next Step **" 40 | run: | 41 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Michał Krassowski All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | # This is useful if you want to use PyPI trusted publisher 20 | # and NPM provenance 21 | id-token: write 22 | steps: 23 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 24 | 25 | - name: Populate Release 26 | id: populate-release 27 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 28 | with: 29 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 30 | branch: ${{ github.event.inputs.branch }} 31 | release_url: ${{ github.event.inputs.release_url }} 32 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 33 | 34 | - name: Finalize Release 35 | id: finalize-release 36 | env: 37 | # The following are needed if you use legacy PyPI set up 38 | # PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 39 | # PYPI_TOKEN_MAP: ${{ secrets.PYPI_TOKEN_MAP }} 40 | # TWINE_USERNAME: __token__ 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 43 | with: 44 | token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 45 | release_url: ${{ steps.populate-release.outputs.release_url }} 46 | 47 | - name: "** Next Step **" 48 | if: ${{ success() }} 49 | run: | 50 | echo "Verify the final release" 51 | echo ${{ steps.finalize-release.outputs.release_url }} 52 | 53 | - name: "** Failure Message **" 54 | if: ${{ failure() }} 55 | run: | 56 | echo "Failed to Publish the Draft Release Url:" 57 | echo ${{ steps.populate-release.outputs.release_url }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | jupyterlab_spreadsheet_editor/labextension 11 | # Version file is handled by hatchling 12 | jupyterlab_spreadsheet_editor/_version.py 13 | 14 | # Created by https://www.gitignore.io/api/python 15 | # Edit at https://www.gitignore.io/?templates=python 16 | 17 | ### Python ### 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | pip-wheel-metadata/ 41 | share/python-wheels/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage/ 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # Mr Developer 100 | .mr.developer.cfg 101 | .project 102 | .pydevproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | # End of https://www.gitignore.io/api/python 116 | 117 | # OSX files 118 | .DS_Store 119 | 120 | # Yarn cache 121 | .yarn/ 122 | -------------------------------------------------------------------------------- /schema/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.shortcuts": [], 3 | "title": "JupyterLab Spreadsheet Editor", 4 | "description": "JupyterLab spreadsheet editor settings.", 5 | "jupyter.lab.menus": { 6 | "context": [ 7 | { 8 | "command": "spreadsheet-editor:copy", 9 | "selector": ".se-area-container .jexcel", 10 | "rank": 0 11 | }, 12 | { 13 | "command": "spreadsheet-editor:paste", 14 | "selector": ".se-area-container .jexcel", 15 | "rank": 1 16 | }, 17 | { 18 | "type": "separator", 19 | "selector": ".se-area-container .jexcel", 20 | "rank": 2 21 | }, 22 | { 23 | "command": "spreadsheet-editor:undo", 24 | "selector": ".se-area-container", 25 | "rank": 3 26 | }, 27 | { 28 | "command": "spreadsheet-editor:redo", 29 | "selector": ".se-area-container", 30 | "rank": 4 31 | }, 32 | { 33 | "type": "separator", 34 | "selector": ".se-area-container .jexcel td", 35 | "rank": 5 36 | }, 37 | { 38 | "command": "spreadsheet-editor:insert-row-above", 39 | "selector": ".se-area-container .jexcel td[data-y]", 40 | "rank": 6 41 | }, 42 | { 43 | "command": "spreadsheet-editor:insert-row-below", 44 | "selector": ".se-area-container .jexcel td[data-y]", 45 | "rank": 7 46 | }, 47 | { 48 | "command": "spreadsheet-editor:insert-column-left", 49 | "selector": ".se-area-container .jexcel td[data-x]", 50 | "rank": 8 51 | }, 52 | { 53 | "command": "spreadsheet-editor:insert-column-right", 54 | "selector": ".se-area-container .jexcel td[data-x]", 55 | "rank": 9 56 | }, 57 | { 58 | "type": "separator", 59 | "selector": ".se-area-container .jexcel td", 60 | "rank": 10 61 | }, 62 | { 63 | "command": "spreadsheet-editor:remove-column", 64 | "selector": ".se-area-container .jexcel td[data-x]", 65 | "rank": 11 66 | }, 67 | { 68 | "command": "spreadsheet-editor:remove-row", 69 | "selector": ".se-area-container .jexcel td[data-y]", 70 | "rank": 12 71 | } 72 | ] 73 | }, 74 | "type": "object", 75 | "properties": {}, 76 | "additionalProperties": false 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Base Setup 18 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | 20 | - name: Install dependencies 21 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 22 | 23 | - name: Lint the extension 24 | run: | 25 | set -eux 26 | jlpm 27 | jlpm run lint:check 28 | 29 | - name: Build the extension 30 | run: | 31 | set -eux 32 | python -m pip install .[test] 33 | 34 | jupyter labextension list 35 | jupyter labextension list 2>&1 | grep -ie "spreadsheet-editor.*OK" 36 | python -m jupyterlab.browser_check 37 | 38 | - name: Package the extension 39 | run: | 40 | set -eux 41 | 42 | pip install build 43 | python -m build 44 | pip uninstall -y "jupyterlab_spreadsheet_editor" jupyterlab 45 | 46 | - name: Upload extension packages 47 | uses: actions/upload-artifact@v3 48 | with: 49 | name: extension-artifacts 50 | path: dist/jupyterlab_spreadsheet_editor* 51 | if-no-files-found: error 52 | 53 | test_isolated: 54 | needs: build 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - name: Install Python 59 | uses: actions/setup-python@v4 60 | with: 61 | python-version: '3.9' 62 | architecture: 'x64' 63 | - uses: actions/download-artifact@v3 64 | with: 65 | name: extension-artifacts 66 | - name: Install and Test 67 | run: | 68 | set -eux 69 | # Remove NodeJS, twice to take care of system and locally installed node versions. 70 | sudo rm -rf $(which node) 71 | sudo rm -rf $(which node) 72 | 73 | pip install "jupyterlab>=4.0.0,<5" jupyterlab_spreadsheet_editor*.whl 74 | 75 | 76 | jupyter labextension list 77 | jupyter labextension list 2>&1 | grep -ie "spreadsheet-editor.*OK" 78 | python -m jupyterlab.browser_check --no-browser-test 79 | 80 | 81 | check_links: 82 | name: Check Links 83 | runs-on: ubuntu-latest 84 | timeout-minutes: 15 85 | steps: 86 | - uses: actions/checkout@v3 87 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 88 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 89 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## 0.7.2 6 | 7 | ([Full Changelog](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/compare/v0.7.1...5743dc620db86b1caa7edaabc3823dd0150f7120)) 8 | 9 | ### Bugs fixed 10 | 11 | - Disable conflicting JSpreadsheet export [#71](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/pull/71) ([@renan-r-santos](https://github.com/renan-r-santos)) 12 | 13 | ### Maintenance and upkeep improvements 14 | 15 | - Update binder environment.yml [#67](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/pull/67) ([@manics](https://github.com/manics)) 16 | 17 | ### Contributors to this release 18 | 19 | ([GitHub contributors page for this release](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/graphs/contributors?from=2024-03-16&to=2024-07-22&type=c)) 20 | 21 | [@github-actions](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyterlab-spreadsheet-editor+involves%3Agithub-actions+updated%3A2024-03-16..2024-07-22&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyterlab-spreadsheet-editor+involves%3Amanics+updated%3A2024-03-16..2024-07-22&type=Issues) | [@renan-r-santos](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyterlab-spreadsheet-editor+involves%3Arenan-r-santos+updated%3A2024-03-16..2024-07-22&type=Issues) 22 | 23 | 24 | 25 | ## 0.7.1 26 | 27 | ([Full Changelog](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/compare/v0.6.1...06a2d2b0772ecf4308b3dc48fb13a7710e6de924)) 28 | 29 | ### Maintenance and upkeep improvements 30 | 31 | - Port to JupyterLab 4 [#64](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/pull/64) ([@krassowski](https://github.com/krassowski)) 32 | 33 | ### Other merged PRs 34 | 35 | - Update name of the default branch in readme and workflows [#55](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/pull/55) ([@krassowski](https://github.com/krassowski)) 36 | 37 | ### Contributors to this release 38 | 39 | ([GitHub contributors page for this release](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/graphs/contributors?from=2021-09-25&to=2024-03-16&type=c)) 40 | 41 | [@github-actions](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyterlab-spreadsheet-editor+involves%3Agithub-actions+updated%3A2021-09-25..2024-03-16&type=Issues) | [@krassowski](https://github.com/search?q=repo%3Ajupyterlab-contrib%2Fjupyterlab-spreadsheet-editor+involves%3Akrassowski+updated%3A2021-09-25..2024-03-16&type=Issues) 42 | -------------------------------------------------------------------------------- /src/statusbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { VDomModel, VDomRenderer } from '@jupyterlab/apputils'; 4 | import { 5 | ITranslator, 6 | nullTranslator, 7 | TranslationBundle 8 | } from '@jupyterlab/translation'; 9 | import { TextItem } from '@jupyterlab/statusbar'; 10 | import { ISelection, SpreadsheetWidget } from './widget'; 11 | 12 | /** 13 | * StatusBar item to display selection span. 14 | */ 15 | export class SelectionStatus extends VDomRenderer { 16 | constructor(translator?: ITranslator) { 17 | super(new SelectionStatus.Model()); 18 | this.translator = translator || nullTranslator; 19 | this._trans = this.translator.load('spreadsheet-editor'); 20 | } 21 | 22 | render() { 23 | if (!this.model) { 24 | return null; 25 | } 26 | const selection = this.model.selection; 27 | if (!selection) { 28 | return ; 29 | } 30 | // if only one cell (or zero cells) is selected, do not show anything 31 | if (selection.rows <= 1 && selection.columns <= 1) { 32 | return ; 33 | } 34 | this.node.title = 35 | this._trans._n('Selected %1 row', 'Selected %1 rows', selection.rows) + 36 | this._trans._n(' and %1 column', ' and %1 columns', selection.columns); 37 | 38 | const text = 39 | this._trans._n('%1 row', '%1 rows', selection.rows) + 40 | this._trans._n(', %1 column', ', %1 columns', selection.columns); 41 | 42 | return ; 43 | } 44 | 45 | protected translator: ITranslator; 46 | private _trans: TranslationBundle; 47 | } 48 | 49 | export namespace SelectionStatus { 50 | export class Model extends VDomModel { 51 | private _spreadsheetWidget: SpreadsheetWidget | null = null; 52 | 53 | get selection(): ISelection | undefined { 54 | return this.spreadsheetWidget?.selection; 55 | } 56 | 57 | set spreadsheetWidget(widget: SpreadsheetWidget | null) { 58 | if (this._spreadsheetWidget) { 59 | this._spreadsheetWidget.selectionChanged.disconnect( 60 | this._triggerChange, 61 | this 62 | ); 63 | } 64 | this._spreadsheetWidget = widget; 65 | if (this._spreadsheetWidget) { 66 | this._spreadsheetWidget.selectionChanged.connect( 67 | this._triggerChange, 68 | this 69 | ); 70 | } 71 | this._triggerChange(); 72 | } 73 | 74 | get spreadsheetWidget(): SpreadsheetWidget | null { 75 | return this._spreadsheetWidget; 76 | } 77 | 78 | private _triggerChange(): void { 79 | this.stateChanged.emit(void 0); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | @import url('~jspreadsheet-ce/dist/jspreadsheet.css'); 2 | @import url('~jspreadsheet-ce/dist/jspreadsheet.theme.css'); 3 | 4 | table.jexcel { 5 | border-top: 0; 6 | border-left: 0; 7 | } 8 | 9 | .jexcel_container { 10 | overflow: visible; 11 | padding: 0; 12 | } 13 | 14 | .jexcel_contextmenu { 15 | display: none; 16 | } 17 | 18 | .jexcel_content { 19 | padding: 0; 20 | } 21 | 22 | .se-area-container { 23 | overflow: auto; 24 | width: 100%; 25 | height: 100%; 26 | } 27 | 28 | .se-column-types { 29 | width: max-content; 30 | height: var(--jp-private-toolbar-height); 31 | } 32 | 33 | .se-column-types select { 34 | display: inline-block; 35 | } 36 | 37 | .se-hidden { 38 | display: none; 39 | } 40 | 41 | svg[data-icon='spreadsheet:column-types'] { 42 | transform: rotate(90deg); 43 | } 44 | 45 | /** 46 | Dark theme 47 | */ 48 | body[data-jp-theme-light='false'] .jexcel_container { 49 | /* use https://bossanova.uk/jexcel/v4/cases/themes tool to adjust */ 50 | --jexcel_header_color: #ccc; 51 | --jexcel_header_color_highlighted: #ddd; 52 | --jexcel_header_background: #313131; 53 | --jexcel_header_background_highlighted: #777; 54 | --jexcel_content_color: #fff; 55 | --jexcel_content_color_highlighted: #333; 56 | --jexcel_content_background: #111; 57 | --jexcel_content_background_highlighted: #333; 58 | --jexcel_menu_background: #7e7e7e; 59 | --jexcel_menu_background_highlighted: #ebebeb; 60 | --jexcel_menu_color: #ddd; 61 | --jexcel_menu_color_highlighted: #222; 62 | --jexcel_menu_box_shadow: unset; 63 | --jexcel_border_color: #5f5f5f; 64 | --jexcel_border_color_highlighted: #999; 65 | --active_color: #eee; 66 | } 67 | 68 | body[data-jp-theme-light='false'] .se-backlight { 69 | background: rgb(85 45 0 / 75%); 70 | } 71 | 72 | /** 73 | Light theme 74 | */ 75 | body[data-jp-theme-light='true'] .jexcel_container { 76 | --jexcel_header_color: #000; 77 | --jexcel_header_color_highlighted: #000; 78 | --jexcel_header_background: #f3f3f3; 79 | --jexcel_header_background_highlighted: #dcdcdc; 80 | --jexcel_content_color: #000; 81 | --jexcel_content_color_highlighted: #000; 82 | --jexcel_content_background: #fff; 83 | --jexcel_content_background_highlighted: rgb(0 0 0 / 5%); 84 | --jexcel_menu_background: #fff; 85 | --jexcel_menu_background_highlighted: #ebebeb; 86 | --jexcel_menu_color: #555; 87 | --jexcel_menu_color_highlighted: #555; 88 | --jexcel_menu_box_shadow: 2px 2px 2px 0px rgb(143 144 145 / 100%); 89 | --jexcel_border_color: #ccc; 90 | --jexcel_border_color_highlighted: #000; 91 | --active_color: #007aff; 92 | } 93 | 94 | body[data-jp-theme-light='true'] .se-backlight { 95 | background: rgb(255 255 0 / 75%); 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '44 0 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.5.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version>=0.3.2"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "jupyterlab-spreadsheet-editor" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 4", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | ] 25 | dependencies = [ 26 | ] 27 | dynamic = ["version", "description", "authors", "urls", "keywords"] 28 | 29 | [tool.hatch.version] 30 | source = "nodejs" 31 | 32 | [tool.hatch.metadata.hooks.nodejs] 33 | fields = ["description", "authors", "urls"] 34 | 35 | [tool.hatch.build.targets.sdist] 36 | artifacts = ["jupyterlab_spreadsheet_editor/labextension"] 37 | exclude = [".github", "binder"] 38 | 39 | [tool.hatch.build.targets.wheel.shared-data] 40 | "jupyterlab_spreadsheet_editor/labextension" = "share/jupyter/labextensions/spreadsheet-editor" 41 | "install.json" = "share/jupyter/labextensions/spreadsheet-editor/install.json" 42 | 43 | [tool.hatch.build.hooks.version] 44 | path = "jupyterlab_spreadsheet_editor/_version.py" 45 | 46 | [tool.hatch.build.hooks.jupyter-builder] 47 | dependencies = ["hatch-jupyter-builder>=0.5"] 48 | build-function = "hatch_jupyter_builder.npm_builder" 49 | ensured-targets = [ 50 | "jupyterlab_spreadsheet_editor/labextension/static/style.js", 51 | "jupyterlab_spreadsheet_editor/labextension/package.json", 52 | ] 53 | skip-if-exists = ["jupyterlab_spreadsheet_editor/labextension/static/style.js"] 54 | 55 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 56 | build_cmd = "build:prod" 57 | npm = ["jlpm"] 58 | 59 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 60 | build_cmd = "install:extension" 61 | npm = ["jlpm"] 62 | source_dir = "src" 63 | build_dir = "jupyterlab_spreadsheet_editor/labextension" 64 | 65 | [tool.jupyter-releaser.options] 66 | version_cmd = "hatch version" 67 | 68 | [tool.jupyter-releaser.hooks] 69 | before-build-npm = [ 70 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 71 | "jlpm", 72 | "jlpm build:prod" 73 | ] 74 | before-build-python = ["jlpm clean:all"] 75 | 76 | [tool.check-wheel-contents] 77 | ignore = ["W002"] 78 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of jupyterlab_spreadsheet_editor 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python packages. All of the Python 10 | packaging instructions are in the `pyproject.toml` file to wrap your extension in a 11 | Python package. Before generating a package, you first need to install some tools: 12 | 13 | ```bash 14 | pip install build twine hatch 15 | ``` 16 | 17 | Bump the version using `hatch`. By default this will create a tag. 18 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 19 | 20 | ```bash 21 | hatch version 22 | ``` 23 | 24 | Make sure to clean up all the development files before building the package: 25 | 26 | ```bash 27 | jlpm clean:all 28 | ``` 29 | 30 | You could also clean up the local git repository: 31 | 32 | ```bash 33 | git clean -dfX 34 | ``` 35 | 36 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 37 | 38 | ```bash 39 | python -m build 40 | ``` 41 | 42 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 43 | 44 | Then to upload the package to PyPI, do: 45 | 46 | ```bash 47 | twine upload dist/* 48 | ``` 49 | 50 | ### NPM package 51 | 52 | To publish the frontend part of the extension as a NPM package, do: 53 | 54 | ```bash 55 | npm login 56 | npm publish --access public 57 | ``` 58 | 59 | ## Automated releases with the Jupyter Releaser 60 | 61 | The extension repository should already be compatible with the Jupyter Releaser. 62 | 63 | Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) for more information. 64 | 65 | Here is a summary of the steps to cut a new release: 66 | 67 | - Add tokens to the [Github Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in the repository: 68 | - `ADMIN_GITHUB_TOKEN` (with "public_repo" and "repo:status" permissions); see the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 69 | - `NPM_TOKEN` (with "automation" permission); see the [documentation](https://docs.npmjs.com/creating-and-viewing-access-tokens) 70 | - Set up PyPI 71 | 72 |
Using PyPI trusted publisher (modern way) 73 | 74 | - Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) 75 | - The _workflow name_ is `publish-release.yml` and the _environment_ should be left blank. 76 | - Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) 77 | 78 |
79 | 80 |
Using PyPI token (legacy way) 81 | 82 | - If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons. 83 | 84 | - You can store the token as `PYPI_TOKEN` in your fork's `Secrets`. 85 | 86 | - Advanced usage: if you are releasing multiple repos, you can create a secret named `PYPI_TOKEN_MAP` instead of `PYPI_TOKEN` that is formatted as follows: 87 | 88 | ```text 89 | owner1/repo1,token1 90 | owner2/repo2,token2 91 | ``` 92 | 93 | If you have multiple Python packages in the same repository, you can point to them as follows: 94 | 95 | ```text 96 | owner1/repo1/path/to/package1,token1 97 | owner1/repo1/path/to/package2,token2 98 | ``` 99 | 100 |
101 | 102 | - Go to the Actions panel 103 | - Run the "Step 1: Prep Release" workflow 104 | - Check the draft changelog 105 | - Run the "Step 2: Publish Release" workflow 106 | 107 | ## Publishing to `conda-forge` 108 | 109 | If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html 110 | 111 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 112 | -------------------------------------------------------------------------------- /style/icons/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Austin Andrews (http://materialdesignicons.com/), 2 | with Reserved Font Name Material Design Icons. 3 | Copyright (c) 2014, Google (http://www.google.com/design/) 4 | uses the license at https://github.com/google/material-design-icons/blob/master/LICENSE 5 | 6 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 7 | This license is copied below, and is also available with a FAQ at: 8 | http://scripts.sil.org/OFL 9 | 10 | 11 | ----------------------------------------------------------- 12 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 13 | ----------------------------------------------------------- 14 | 15 | PREAMBLE 16 | The goals of the Open Font License (OFL) are to stimulate worldwide 17 | development of collaborative font projects, to support the font creation 18 | efforts of academic and linguistic communities, and to provide a free and 19 | open framework in which fonts may be shared and improved in partnership 20 | with others. 21 | 22 | The OFL allows the licensed fonts to be used, studied, modified and 23 | redistributed freely as long as they are not sold by themselves. The 24 | fonts, including any derivative works, can be bundled, embedded, 25 | redistributed and/or sold with any software provided that any reserved 26 | names are not used by derivative works. The fonts and derivatives, 27 | however, cannot be released under any other type of license. The 28 | requirement for fonts to remain under this license does not apply 29 | to any document created using the fonts or their derivatives. 30 | 31 | DEFINITIONS 32 | "Font Software" refers to the set of files released by the Copyright 33 | Holder(s) under this license and clearly marked as such. This may 34 | include source files, build scripts and documentation. 35 | 36 | "Reserved Font Name" refers to any names specified as such after the 37 | copyright statement(s). 38 | 39 | "Original Version" refers to the collection of Font Software components as 40 | distributed by the Copyright Holder(s). 41 | 42 | "Modified Version" refers to any derivative made by adding to, deleting, 43 | or substituting -- in part or in whole -- any of the components of the 44 | Original Version, by changing formats or by porting the Font Software to a 45 | new environment. 46 | 47 | "Author" refers to any designer, engineer, programmer, technical 48 | writer or other person who contributed to the Font Software. 49 | 50 | PERMISSION & CONDITIONS 51 | Permission is hereby granted, free of charge, to any person obtaining 52 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 53 | redistribute, and sell modified and unmodified copies of the Font 54 | Software, subject to the following conditions: 55 | 56 | 1) Neither the Font Software nor any of its individual components, 57 | in Original or Modified Versions, may be sold by itself. 58 | 59 | 2) Original or Modified Versions of the Font Software may be bundled, 60 | redistributed and/or sold with any software, provided that each copy 61 | contains the above copyright notice and this license. These can be 62 | included either as stand-alone text files, human-readable headers or 63 | in the appropriate machine-readable metadata fields within text or 64 | binary files as long as those fields can be easily viewed by the user. 65 | 66 | 3) No Modified Version of the Font Software may use the Reserved Font 67 | Name(s) unless explicit written permission is granted by the corresponding 68 | Copyright Holder. This restriction only applies to the primary font name as 69 | presented to the users. 70 | 71 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 72 | Software shall not be used to promote, endorse or advertise any 73 | Modified Version, except to acknowledge the contribution(s) of the 74 | Copyright Holder(s) and the Author(s) or with their explicit written 75 | permission. 76 | 77 | 5) The Font Software, modified or unmodified, in part or in whole, 78 | must be distributed entirely under this license, and must not be 79 | distributed under any other license. The requirement for fonts to 80 | remain under this license does not apply to any document created 81 | using the Font Software. 82 | 83 | TERMINATION 84 | This license becomes null and void if any of the above conditions are 85 | not met. 86 | 87 | DISCLAIMER 88 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 89 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 90 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 91 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 92 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 93 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 94 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 95 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 96 | OTHER DEALINGS IN THE FONT SOFTWARE. 97 | 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JupyterLab Spreadsheet Editor 2 | 3 | [![Extension status](https://img.shields.io/badge/status-ready-success 'ready to be used')](https://jupyterlab-contrib.github.io/) 4 | [![GitHub Action Status](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/actions/workflows/build.yml/badge.svg)](https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/actions/workflows/build.yml) 5 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jupyterlab-contrib/jupyterlab-spreadsheet-editor/main?urlpath=lab) 6 | [![pypi-version](https://img.shields.io/pypi/v/jupyterlab-spreadsheet-editor.svg)](https://python.org/pypi/jupyterlab-spreadsheet-editor) 7 | 8 | JupyterLab spreadsheet editor enables interactive editing of comma/tab separated value spreadsheets. 9 | It support formulas, sorting, column/row rearrangements and more! 10 | 11 | > Note: you might be interested to checkout [tabular-data-editor](https://github.com/jupytercalpoly/jupyterlab-tabular-data-editor) as well 12 | 13 | ## Showcase 14 | 15 | **Fully featured integration** 16 | 17 | - row/column operations, column width adjustment 18 | - search and replace 19 | 20 | ![](https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/main/screenshots/setosa-demo.gif) 21 | 22 | **Formula support** 23 | basic formula calculation (rendering) - as implemented by jExcel. 24 | 25 | ![](https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/main/screenshots/formula-support.gif) 26 | 27 | **Column freezing** 28 | for exploration of wide datasets with many covariates 29 | 30 | ![](https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/main/screenshots/freeze-support.gif) 31 | 32 | **Launcher items**: 33 | create CSV/TSV files easily from the launcher or the palette. 34 | 35 | ![](https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/main/screenshots/launcher.png) 36 | 37 | **Lightweight and reliable dependencies**: 38 | the spreadsheet interface is built with the [jexcel](https://github.com/paulhodel/jexcel), while [Papa Parse](https://github.com/mholt/PapaParse) provides very fast, [RFC 4180](https://tools.ietf.org/html/rfc4180) compatible CSV parsing (both have no third-party dependencies). 39 | 40 | ## Requirements 41 | 42 | - JupyterLab >= 3.0 43 | 44 | ## Install 45 | 46 | ```bash 47 | pip install jupyterlab-spreadsheet-editor 48 | ``` 49 | 50 | ## Contributing 51 | 52 | ### Development install 53 | 54 | Note: You will need NodeJS to build the extension package. 55 | 56 | The `jlpm` command is JupyterLab's pinned version of 57 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 58 | `yarn` or `npm` in lieu of `jlpm` below. 59 | 60 | ```bash 61 | # Clone the repo to your local environment 62 | # Change directory to the jupyterlab-spreadsheet-editor directory 63 | # Install package in development mode 64 | pip install -e . 65 | # Link your development version of the extension with JupyterLab 66 | jupyter labextension develop . --overwrite 67 | # Rebuild extension Typescript source after making changes 68 | jlpm run build 69 | ``` 70 | 71 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 72 | 73 | ```bash 74 | # Watch the source directory in one terminal, automatically rebuilding when needed 75 | jlpm run watch 76 | # Run JupyterLab in another terminal 77 | jupyter lab 78 | ``` 79 | 80 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 81 | 82 | By default, the `jlpm run build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 83 | 84 | ```bash 85 | jupyter lab build --minimize=False 86 | ``` 87 | 88 | ### Uninstall 89 | 90 | ```bash 91 | pip uninstall jupyterlab-spreadsheet-editor 92 | ``` 93 | 94 | ## Related extensions 95 | 96 | Spreadsheet editors: 97 | 98 | - [jupyterlab-tabular-data-editor](https://github.com/jupytercalpoly/jupyterlab-tabular-data-editor) 99 | 100 | Spreadsheet viewers: 101 | 102 | - [jupyterlab-spreadsheet](https://github.com/quigleyj97/jupyterlab-spreadsheet) implements Excel spreadsheet viewer 103 | - the built-in [csvviewer](https://github.com/jupyterlab/jupyterlab/tree/master/packages/csvviewer) ([extension](https://github.com/jupyterlab/jupyterlab/tree/master/packages/csvviewer-extension)) allows to display CSV/TSV files 104 | 105 | In-notebook spreadsheet widgets: 106 | 107 | - [ipysheet](https://github.com/QuantStack/ipysheet) - programmable sheet creation, exploration and modification 108 | - [qgrid](https://github.com/quantopian/qgrid) - interactive DataFrame exploration and modification 109 | -------------------------------------------------------------------------------- /src/documentwidget.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { DocumentWidget } from '@jupyterlab/docregistry'; 5 | import { SpreadsheetWidget } from './widget'; 6 | import { ToolbarButton } from '@jupyterlab/apputils'; 7 | import { LabIcon } from '@jupyterlab/ui-components'; 8 | import numberedSvg from '../style/icons/mdi-format-list-numbered.svg'; 9 | import fitColumnWidthSvg from '../style/icons/mdi-table-column-width.svg'; 10 | import listTypeSvg from '../style/icons/mdi-format-list-bulleted-type.svg'; 11 | import topHeaderSvg from '../style/icons/mdi-page-layout-header.svg'; 12 | import { 13 | freezeColumnIcon, 14 | unfreezeColumnIcon, 15 | removeColumnIcon, 16 | addColumnIcon, 17 | removeRowIcon, 18 | addRowIcon 19 | } from './icons'; 20 | import { nullTranslator, TranslationBundle } from '@jupyterlab/translation'; 21 | 22 | export class SpreadsheetEditorDocumentWidget extends DocumentWidget { 23 | protected _trans: TranslationBundle; 24 | 25 | constructor(options: DocumentWidget.IOptions) { 26 | super(options); 27 | const translator = options.translator || nullTranslator; 28 | this._trans = translator.load('spreadsheet-editor'); 29 | 30 | const addRowButton = new ToolbarButton({ 31 | icon: addRowIcon, 32 | onClick: () => { 33 | this.content.jexcel!.insertRow(); 34 | this.content.updateModel(); 35 | }, 36 | tooltip: this._trans.__('Insert a row at the end') 37 | }); 38 | this.toolbar.addItem('spreadsheet:insert-row', addRowButton); 39 | 40 | const removeRowButton = new ToolbarButton({ 41 | icon: removeRowIcon, 42 | onClick: () => { 43 | this.content.jexcel!.deleteRow(this.content.jexcel!.rows.length - 1, 1); 44 | this.content.updateModel(); 45 | }, 46 | tooltip: this._trans.__('Remove the last row') 47 | }); 48 | this.toolbar.addItem('spreadsheet:remove-row', removeRowButton); 49 | 50 | const addColumnButton = new ToolbarButton({ 51 | icon: addColumnIcon, 52 | onClick: () => { 53 | this.content.jexcel!.insertColumn(); 54 | this.content.updateModel(); 55 | }, 56 | tooltip: this._trans.__('Insert a column at the end') 57 | }); 58 | this.toolbar.addItem('spreadsheet:insert-column', addColumnButton); 59 | 60 | const removeColumnButton = new ToolbarButton({ 61 | icon: removeColumnIcon, 62 | onClick: () => { 63 | this.content.jexcel!.deleteColumn(this.content.columnsNumber); 64 | this.content.updateModel(); 65 | }, 66 | tooltip: this._trans.__('Remove the last column') 67 | }); 68 | this.toolbar.addItem('spreadsheet:remove-column', removeColumnButton); 69 | 70 | let indexVisible = true; 71 | const showHideIndex = new ToolbarButton({ 72 | icon: new LabIcon({ 73 | name: 'spreadsheet:show-hide-index', 74 | svgstr: numberedSvg 75 | }), 76 | onClick: () => { 77 | if (indexVisible) { 78 | this.content.jexcel!.hideIndex(); 79 | } else { 80 | this.content.jexcel!.showIndex(); 81 | } 82 | indexVisible = !indexVisible; 83 | }, 84 | tooltip: this._trans.__('Show/hide row numbers') 85 | }); 86 | this.toolbar.addItem('spreadsheet:show-hide-index', showHideIndex); 87 | 88 | const fitColumnWidthButton = new ToolbarButton({ 89 | icon: new LabIcon({ 90 | name: 'spreadsheet:fit-columns', 91 | svgstr: fitColumnWidthSvg 92 | }), 93 | onClick: () => { 94 | switch (this.content.fitMode) { 95 | case 'fit-cells': 96 | this.content.fitMode = 'all-equal-fit'; 97 | break; 98 | case 'all-equal-fit': 99 | this.content.fitMode = 'all-equal-default'; 100 | break; 101 | case 'all-equal-default': 102 | this.content.fitMode = 'fit-cells'; 103 | } 104 | this.content.relayout(); 105 | }, 106 | tooltip: this._trans.__('Fit columns width') 107 | }); 108 | this.toolbar.addItem('spreadsheet:fit-columns', fitColumnWidthButton); 109 | 110 | const freezeColumnButton = new ToolbarButton({ 111 | icon: freezeColumnIcon, 112 | onClick: () => { 113 | this.content.freezeSelectedColumns(); 114 | }, 115 | tooltip: this._trans.__( 116 | 'Freeze the initial columns (up to the selected column)' 117 | ) 118 | }); 119 | this.toolbar.addItem('spreadsheet:freeze-columns', freezeColumnButton); 120 | 121 | const unfreezeColumnButton = new ToolbarButton({ 122 | icon: unfreezeColumnIcon, 123 | onClick: () => { 124 | this.content.unfreezeColumns(); 125 | }, 126 | tooltip: this._trans.__('Unfreeze frozen column') 127 | }); 128 | this.toolbar.addItem('spreadsheet:unfreeze-columns', unfreezeColumnButton); 129 | 130 | const switchHeadersButton = new ToolbarButton({ 131 | icon: new LabIcon({ 132 | name: 'spreadsheet:switch-headers', 133 | svgstr: topHeaderSvg 134 | }), 135 | onClick: () => { 136 | this.content.switchHeaders(); 137 | }, 138 | tooltip: this._trans.__('First row as a header') 139 | }); 140 | this.toolbar.addItem('spreadsheet:switch-headers', switchHeadersButton); 141 | 142 | const columnTypeButton = new ToolbarButton({ 143 | icon: new LabIcon({ 144 | name: 'spreadsheet:column-types', 145 | svgstr: listTypeSvg 146 | }), 147 | onClick: () => { 148 | this.content.switchTypesBar(); 149 | }, 150 | tooltip: this._trans.__('Show/hide column types bar') 151 | }); 152 | this.toolbar.addItem( 153 | 'spreadsheet:switch-column-types-bar', 154 | columnTypeButton 155 | ); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spreadsheet-editor", 3 | "version": "0.7.2", 4 | "description": "JupyterLab spreadsheet (csv/tsv) editor", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension", 9 | "csv", 10 | "tsv", 11 | "spreadsheeet", 12 | "excel" 13 | ], 14 | "homepage": "https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor", 15 | "bugs": { 16 | "url": "https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor/issues" 17 | }, 18 | "license": "BSD-3-Clause", 19 | "author": "Michał Krassowski", 20 | "files": [ 21 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 22 | "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", 23 | "style/icons/**/*.{svg}", 24 | "style/index.js", 25 | "schema/*.json" 26 | ], 27 | "main": "lib/index.js", 28 | "types": "lib/index.d.ts", 29 | "style": "style/index.css", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/jupyterlab-contrib/jupyterlab-spreadsheet-editor.git" 33 | }, 34 | "scripts": { 35 | "build": "jlpm build:lib && jlpm build:labextension:dev", 36 | "build:labextension": "jupyter labextension build .", 37 | "build:labextension:dev": "jupyter labextension build --development True .", 38 | "build:lib": "tsc --sourceMap", 39 | "build:lib:prod": "tsc", 40 | "build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension", 41 | "clean": "jlpm clean:lib", 42 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 43 | "clean:labextension": "rimraf jupyterlab_spreadsheet_editor/labextension jupyterlab_spreadsheet_editor/_version.py", 44 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 45 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 46 | "eslint": "jlpm eslint:check --fix", 47 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 48 | "install:extension": "jlpm build", 49 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 50 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 51 | "prettier": "jlpm prettier:base --write --list-different", 52 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 53 | "prettier:check": "jlpm prettier:base --check", 54 | "stylelint": "jlpm stylelint:check --fix", 55 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 56 | "watch": "run-p watch:src watch:labextension", 57 | "watch:labextension": "jupyter labextension watch .", 58 | "watch:src": "tsc -w --sourceMap" 59 | }, 60 | "dependencies": { 61 | "@jupyterlab/application": "^4.0.11", 62 | "@jupyterlab/apputils": "^4.1.11", 63 | "@jupyterlab/coreutils": "^6.0.11", 64 | "@jupyterlab/docregistry": "^4.0.11", 65 | "@jupyterlab/documentsearch": "^4.0.11", 66 | "@jupyterlab/filebrowser": "^4.0.11", 67 | "@jupyterlab/fileeditor": "^4.0.11", 68 | "@jupyterlab/launcher": "^4.0.11", 69 | "@jupyterlab/mainmenu": "^4.0.11", 70 | "@jupyterlab/statusbar": "^4.0.11", 71 | "@jupyterlab/translation": "^4.0.11", 72 | "@jupyterlab/ui-components": "^4.0.11", 73 | "@lumino/commands": "^2.0.1", 74 | "@lumino/coreutils": "^2.0.0", 75 | "@lumino/messaging": "^2.0.0", 76 | "@lumino/signaling": "^2.0.0", 77 | "@lumino/widgets": "^2.0.1", 78 | "@types/papaparse": "^5.3.14", 79 | "jspreadsheet-ce": "^4.13.4", 80 | "papaparse": "^5.4.1" 81 | }, 82 | "devDependencies": { 83 | "@jupyterlab/builder": "^4.0.0", 84 | "@types/json-schema": "^7.0.11", 85 | "@types/react": "^18.0.26", 86 | "@types/react-addons-linked-state-mixin": "^0.14.22", 87 | "@typescript-eslint/eslint-plugin": "^6.1.0", 88 | "@typescript-eslint/parser": "^6.1.0", 89 | "css-loader": "^6.7.1", 90 | "eslint": "^8.36.0", 91 | "eslint-config-prettier": "^8.8.0", 92 | "eslint-plugin-prettier": "^5.0.0", 93 | "eslint-plugin-react": "^7.19.0", 94 | "npm-run-all": "^4.1.5", 95 | "prettier": "^3.0.0", 96 | "rimraf": "^5.0.1", 97 | "source-map-loader": "^1.0.2", 98 | "style-loader": "^3.3.1", 99 | "stylelint": "^15.10.1", 100 | "stylelint-config-recommended": "^13.0.0", 101 | "stylelint-config-standard": "^34.0.0", 102 | "stylelint-csstree-validator": "^3.0.0", 103 | "stylelint-prettier": "^4.0.0", 104 | "typescript": "~5.0.2", 105 | "yjs": "^13.5.40" 106 | }, 107 | "sideEffects": [ 108 | "style/*.css", 109 | "style/index.js" 110 | ], 111 | "jupyterlab": { 112 | "extension": true, 113 | "outputDir": "jupyterlab_spreadsheet_editor/labextension", 114 | "schemaDir": "schema" 115 | }, 116 | "styleModule": "style/index.js", 117 | "eslintConfig": { 118 | "extends": [ 119 | "eslint:recommended", 120 | "plugin:@typescript-eslint/eslint-recommended", 121 | "plugin:@typescript-eslint/recommended", 122 | "plugin:prettier/recommended" 123 | ], 124 | "parser": "@typescript-eslint/parser", 125 | "parserOptions": { 126 | "project": "tsconfig.json", 127 | "sourceType": "module" 128 | }, 129 | "plugins": [ 130 | "@typescript-eslint" 131 | ], 132 | "rules": { 133 | "@typescript-eslint/naming-convention": [ 134 | "error", 135 | { 136 | "selector": "interface", 137 | "format": [ 138 | "PascalCase" 139 | ], 140 | "custom": { 141 | "regex": "^I[A-Z]", 142 | "match": true 143 | } 144 | } 145 | ], 146 | "@typescript-eslint/no-unused-vars": [ 147 | "warn", 148 | { 149 | "args": "none" 150 | } 151 | ], 152 | "@typescript-eslint/no-explicit-any": "off", 153 | "@typescript-eslint/no-namespace": "off", 154 | "@typescript-eslint/no-use-before-define": "off", 155 | "@typescript-eslint/quotes": [ 156 | "error", 157 | "single", 158 | { 159 | "avoidEscape": true, 160 | "allowTemplateLiterals": false 161 | } 162 | ], 163 | "curly": [ 164 | "error", 165 | "all" 166 | ], 167 | "eqeqeq": "error", 168 | "prefer-arrow-callback": "error" 169 | } 170 | }, 171 | "eslintIgnore": [ 172 | "node_modules", 173 | "dist", 174 | "coverage", 175 | "**/*.d.ts" 176 | ], 177 | "prettier": { 178 | "singleQuote": true, 179 | "trailingComma": "none", 180 | "arrowParens": "avoid", 181 | "endOfLine": "auto", 182 | "overrides": [ 183 | { 184 | "files": "package.json", 185 | "options": { 186 | "tabWidth": 4 187 | } 188 | } 189 | ] 190 | }, 191 | "stylelint": { 192 | "extends": [ 193 | "stylelint-config-recommended", 194 | "stylelint-config-standard", 195 | "stylelint-prettier/recommended" 196 | ], 197 | "plugins": [ 198 | "stylelint-csstree-validator" 199 | ], 200 | "rules": { 201 | "csstree/validator": true, 202 | "property-no-vendor-prefix": null, 203 | "selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$", 204 | "selector-no-vendor-prefix": null, 205 | "value-no-vendor-prefix": null, 206 | "custom-property-pattern": null 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/searchprovider.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | ISearchMatch, 6 | ISearchProvider, 7 | ISearchProviderFactory, 8 | SearchProvider, 9 | IFilters 10 | } from '@jupyterlab/documentsearch'; 11 | import { SpreadsheetEditorDocumentWidget } from './documentwidget'; 12 | import { Widget } from '@lumino/widgets'; 13 | import { DocumentWidget } from '@jupyterlab/docregistry'; 14 | import { SpreadsheetWidget } from './widget'; 15 | import { ISignal, Signal } from '@lumino/signaling'; 16 | import { JspreadsheetInstance } from 'jspreadsheet-ce'; 17 | 18 | export interface ICellCoordinates { 19 | column: number; 20 | row: number; 21 | } 22 | 23 | interface ICellSearchMatch extends ISearchMatch, ICellCoordinates { 24 | // no-op 25 | } 26 | 27 | class CoordinatesSet { 28 | protected _set: Set; 29 | 30 | constructor() { 31 | this._set = new Set(); 32 | } 33 | 34 | protected _valueToString(value: ICellCoordinates): string { 35 | if (typeof value === 'undefined') { 36 | return value; 37 | } 38 | return value.column + '|' + value.row; 39 | } 40 | 41 | protected _stringToValue(value: string): ICellCoordinates { 42 | if (typeof value === 'undefined') { 43 | return value; 44 | } 45 | const parts = value.split('|'); 46 | if (parts.length !== 2) { 47 | console.warn('A problem with stringToValue input detected!'); 48 | } 49 | return { 50 | column: parseInt(parts[0], 10), 51 | row: parseInt(parts[1], 10) 52 | }; 53 | } 54 | 55 | add(value: ICellCoordinates): this { 56 | this._set.add(this._valueToString(value)); 57 | return this; 58 | } 59 | 60 | delete(value: ICellCoordinates): boolean { 61 | return this._set.delete(this._valueToString(value)); 62 | } 63 | 64 | has(value: ICellCoordinates): boolean { 65 | return this._set.has(this._valueToString(value)); 66 | } 67 | 68 | values(): IterableIterator { 69 | const iterator = this._set.values(); 70 | const stringToValue = this._stringToValue; 71 | return { 72 | [Symbol.iterator](): IterableIterator { 73 | return this; 74 | }, 75 | return(value: any): IteratorResult { 76 | const returned = iterator.return!(value); 77 | return { 78 | done: returned.done, 79 | value: stringToValue(returned.value) 80 | }; 81 | }, 82 | next() { 83 | const next = iterator.next(); 84 | return { 85 | done: next.done, 86 | value: stringToValue(next.value) 87 | }; 88 | }, 89 | throw() { 90 | const thrown = iterator.throw!(); 91 | return { 92 | done: thrown.done, 93 | value: stringToValue(thrown.value) 94 | }; 95 | } 96 | }; 97 | } 98 | 99 | clear() { 100 | return this._set.clear(); 101 | } 102 | } 103 | 104 | export class SpreadsheetSearchProviderFactory 105 | implements ISearchProviderFactory 106 | { 107 | createNew( 108 | widget: Widget 109 | //translator?: ITranslator 110 | ): ISearchProvider { 111 | return new SpreadsheetSearchProvider( 112 | widget as SpreadsheetEditorDocumentWidget 113 | ); 114 | } 115 | 116 | /** 117 | * Report whether or not this provider has the ability to search on the given object 118 | */ 119 | isApplicable(domain: Widget): domain is SpreadsheetEditorDocumentWidget { 120 | // check to see if the SpreadsheetSearchProvider can search on the 121 | // first cell, false indicates another editor is present 122 | return ( 123 | domain instanceof DocumentWidget && 124 | domain.content instanceof SpreadsheetWidget 125 | ); 126 | } 127 | } 128 | 129 | export class SpreadsheetSearchProvider extends SearchProvider { 130 | constructor(widget: SpreadsheetEditorDocumentWidget) { 131 | super(widget); 132 | this._sheet = widget.content; 133 | this._target = this._sheet.jexcel!; 134 | this.backlitMatches = new CoordinatesSet(); 135 | } 136 | 137 | private mostRecentSelectedCell: any; 138 | 139 | get changed(): ISignal { 140 | return this._changed; 141 | } 142 | 143 | get currentMatchIndex(): number | null { 144 | return this._currentMatchIndex; 145 | } 146 | 147 | readonly isReadOnly: boolean = false; 148 | 149 | get matches(): ICellSearchMatch[] { 150 | return this._matches; 151 | } 152 | 153 | get matchesCount(): number | null { 154 | return this._matches.length; 155 | } 156 | 157 | endQuery(): Promise { 158 | this.backlightOff(); 159 | this._currentMatchIndex = 0; 160 | this._matches = []; 161 | this._sheet.changed.disconnect(this._onSheetChanged, this); 162 | return Promise.resolve(undefined); 163 | } 164 | 165 | /** 166 | * Clear currently highlighted match. 167 | */ 168 | async clearHighlight(): Promise { 169 | this.backlightOff(); 170 | } 171 | 172 | private backlightOff() { 173 | for (const matchCoords of this.backlitMatches.values()) { 174 | const cell: HTMLElement = this._target.getCellFromCoords( 175 | matchCoords.column, 176 | matchCoords.row 177 | ); 178 | cell.classList.remove('se-backlight'); 179 | } 180 | this.backlitMatches.clear(); 181 | } 182 | 183 | async endSearch(): Promise { 184 | // restore the selection 185 | // eslint-disable-next-line eqeqeq 186 | if (this._target.selectedCell == null && this.mostRecentSelectedCell) { 187 | this._target.selectedCell = this.mostRecentSelectedCell; 188 | } 189 | return this.endQuery(); 190 | } 191 | 192 | private getSelectedCellCoordinates(): ICellCoordinates | null { 193 | const target = this._target; 194 | const columns = target.getSelectedColumns(); 195 | const rows = target.getSelectedRows(true) as number[]; 196 | if (rows.length === 1 && columns.length === 1) { 197 | return { 198 | column: columns[0], 199 | row: rows[0] 200 | }; 201 | } 202 | return null; 203 | } 204 | 205 | private _initialQueryCoodrs: ICellCoordinates | null = null; 206 | 207 | getInitialQuery(): string { 208 | const coords = this.getSelectedCellCoordinates(); 209 | this._initialQueryCoodrs = coords; 210 | if (coords) { 211 | const value = this._target.getValueFromCoords( 212 | coords.column, 213 | coords.row, 214 | false 215 | ); 216 | if (value) { 217 | return value.toString(); 218 | } 219 | } 220 | // Close the editor to avoid overwriting contents of last edited cell 221 | // as users starts typing into the search box after pressing ctrl + f 222 | // (but only do that after the initial value was taken) 223 | this._target.resetSelection(true); 224 | return ''; 225 | } 226 | 227 | async highlightNext(): Promise { 228 | if (this._currentMatchIndex + 1 < this.matches.length) { 229 | this._currentMatchIndex += 1; 230 | } else { 231 | this._currentMatchIndex = 0; 232 | } 233 | const match = this.matches[this._currentMatchIndex]; 234 | if (!match) { 235 | return; 236 | } 237 | this.highlight(match); 238 | return match; 239 | } 240 | 241 | async highlightPrevious(): Promise { 242 | if (this._currentMatchIndex > 0) { 243 | this._currentMatchIndex -= 1; 244 | } else { 245 | this._currentMatchIndex = this.matches.length - 1; 246 | } 247 | const match = this.matches[this._currentMatchIndex]; 248 | if (!match) { 249 | return; 250 | } 251 | this.highlight(match); 252 | return match; 253 | } 254 | 255 | highlight(match: ICellSearchMatch) { 256 | this.backlightMatches(); 257 | // select the matched cell, which leads to a loss of "focus" (or rather jexcel eagerly intercepting events) 258 | this._target.updateSelectionFromCoords( 259 | match.column, 260 | match.row, 261 | match.column, 262 | match.row, 263 | null 264 | ); 265 | // "regain" focus by erasing selection information (but keeping all the CSS) - this is a workaround (best avoided) 266 | this.mostRecentSelectedCell = this._target.selectedCell; 267 | this._target.selectedCell = null; 268 | this._sheet.scrollCellIntoView({ row: match.row, column: match.column }); 269 | } 270 | 271 | async replaceAllMatches(newText: string): Promise { 272 | for (let i = 0; i < this.matches.length; i++) { 273 | this._currentMatchIndex = i; 274 | await this.replaceCurrentMatch(newText, true); 275 | } 276 | this._matches = this.findMatches(); 277 | this.backlightMatches(); 278 | return true; 279 | } 280 | 281 | async replaceCurrentMatch( 282 | newText: string, 283 | isReplaceAll = false 284 | ): Promise { 285 | let replaceOccurred = false; 286 | const match = this.matches[this._currentMatchIndex]; 287 | const cell = this._target.getValueFromCoords( 288 | match.column, 289 | match.row, 290 | false 291 | ); 292 | let index = -1; 293 | let matchesInCell = 0; 294 | 295 | const newValue = String(cell).replace(this._query!, substring => { 296 | index += 1; 297 | matchesInCell += 1; 298 | if (index === match.position) { 299 | replaceOccurred = true; 300 | return newText; 301 | } 302 | 303 | return substring; 304 | }); 305 | let subsequentIndex = this._currentMatchIndex + 1; 306 | while (subsequentIndex < this.matches.length) { 307 | const subsequent = this.matches[subsequentIndex]; 308 | if (subsequent.column === match.column && subsequent.row === match.row) { 309 | subsequent.position -= 1; 310 | } else { 311 | break; 312 | } 313 | subsequentIndex += 1; 314 | } 315 | 316 | this._target.setValueFromCoords(match.column, match.row, newValue, false); 317 | 318 | if (!isReplaceAll && matchesInCell === 1) { 319 | const matchCoords = { column: match.column, row: match.row }; 320 | const cell: HTMLElement = this._target.getCellFromCoords( 321 | match.column, 322 | match.row 323 | ); 324 | cell.classList.remove('se-backlight'); 325 | this.backlitMatches.delete(matchCoords); 326 | } 327 | 328 | if (!isReplaceAll) { 329 | await this.highlightNext(); 330 | } 331 | return replaceOccurred; 332 | } 333 | 334 | private _onSheetChanged() { 335 | // matches may need updating 336 | this._matches = this.findMatches(false); 337 | // update backlight 338 | this.backlightOff(); 339 | this.backlightMatches(); 340 | this._changed.emit(undefined); 341 | } 342 | 343 | protected backlitMatches: CoordinatesSet; 344 | 345 | /** 346 | * Highlight n=1000 matches around the current match. 347 | * The number of highlights is limited to prevent negative impact on the UX in huge notebooks. 348 | */ 349 | protected backlightMatches(n = 1000): void { 350 | for ( 351 | let i = Math.max(0, this._currentMatchIndex - n / 2); 352 | i < Math.min(this._currentMatchIndex + n / 2, this.matches.length); 353 | i++ 354 | ) { 355 | const match = this.matches[i]; 356 | const matchCoord = { 357 | column: match.column, 358 | row: match.row 359 | }; 360 | 361 | if (!this.backlitMatches.has(matchCoord)) { 362 | const cell: HTMLElement = this._target.getCellFromCoords( 363 | match.column, 364 | match.row 365 | ); 366 | cell.classList.add('se-backlight'); 367 | this.backlitMatches.add(matchCoord); 368 | } 369 | } 370 | } 371 | 372 | protected findMatches(highlightFirst = true): ICellSearchMatch[] { 373 | const currentCellCoordinates = this._initialQueryCoodrs; 374 | this._initialQueryCoodrs = null; 375 | let currentMatchIndex = 0; 376 | const query = this._query; 377 | if (!query) { 378 | return []; 379 | } 380 | 381 | const matches: ICellSearchMatch[] = []; 382 | const data = this._target.getData(); 383 | let rowNumber = 0; 384 | let columnNumber = -1; 385 | let index = 0; 386 | let totalMatchIndex = 0; 387 | for (const row of data) { 388 | for (const cell of row) { 389 | columnNumber += 1; 390 | if (!cell) { 391 | continue; 392 | } 393 | const matched = String(cell).match(query!); 394 | if (!matched) { 395 | continue; 396 | } 397 | index = 0; 398 | if ( 399 | currentCellCoordinates !== null && 400 | currentCellCoordinates.row === rowNumber && 401 | currentCellCoordinates.column === columnNumber 402 | ) { 403 | currentMatchIndex = totalMatchIndex; 404 | } 405 | for (const match of matched) { 406 | matches.push({ 407 | row: rowNumber, 408 | column: columnNumber, 409 | position: index, 410 | text: match 411 | }); 412 | index += 1; 413 | totalMatchIndex += 1; 414 | } 415 | } 416 | columnNumber = -1; 417 | rowNumber += 1; 418 | } 419 | this._currentMatchIndex = currentMatchIndex; 420 | this._matches = matches; 421 | 422 | if (matches.length && highlightFirst) { 423 | this.highlight(matches[this._currentMatchIndex]); 424 | } 425 | 426 | return matches; 427 | } 428 | async startQuery(query: RegExp, filters: IFilters): Promise { 429 | const searchTarget = this.widget; 430 | this._sheet = searchTarget.content; 431 | this._query = query; 432 | 433 | this._sheet.changed.connect(this._onSheetChanged, this); 434 | 435 | this.findMatches(); 436 | } 437 | 438 | private _changed = new Signal(this); 439 | 440 | private _target: JspreadsheetInstance; 441 | private _sheet: SpreadsheetWidget; 442 | private _query: RegExp | null = null; 443 | private _matches: ICellSearchMatch[] = []; 444 | private _currentMatchIndex: number = 0; 445 | } 446 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { 5 | ILayoutRestorer, 6 | JupyterFrontEnd, 7 | JupyterFrontEndPlugin 8 | } from '@jupyterlab/application'; 9 | import { IMainMenu } from '@jupyterlab/mainmenu'; 10 | import { 11 | ABCWidgetFactory, 12 | DocumentRegistry, 13 | IDocumentWidget 14 | } from '@jupyterlab/docregistry'; 15 | import { Widget } from '@lumino/widgets'; 16 | import { DocumentWidget } from '@jupyterlab/docregistry'; 17 | import { ICommandPalette, WidgetTracker } from '@jupyterlab/apputils'; 18 | import { 19 | redoIcon, 20 | undoIcon, 21 | addAboveIcon, 22 | addBelowIcon, 23 | addIcon, 24 | copyIcon, 25 | pasteIcon 26 | } from '@jupyterlab/ui-components'; 27 | import { ISearchProviderRegistry } from '@jupyterlab/documentsearch'; 28 | import { CommandRegistry } from '@lumino/commands'; 29 | 30 | import '../style/index.css'; 31 | import { SpreadsheetWidget } from './widget'; 32 | import { SpreadsheetEditorDocumentWidget } from './documentwidget'; 33 | import { SpreadsheetSearchProviderFactory } from './searchprovider'; 34 | import { ILauncher } from '@jupyterlab/launcher'; 35 | import { spreadsheetIcon } from '@jupyterlab/ui-components'; 36 | import { 37 | IFileBrowserFactory, 38 | IDefaultFileBrowser 39 | } from '@jupyterlab/filebrowser'; 40 | import { IStatusBar } from '@jupyterlab/statusbar'; 41 | import { SelectionStatus } from './statusbar'; 42 | import { 43 | ITranslator, 44 | nullTranslator, 45 | TranslationBundle 46 | } from '@jupyterlab/translation'; 47 | import { removeColumnIcon, removeRowIcon } from './icons'; 48 | 49 | const paletteCategory = 'Spreadsheet Editor'; 50 | 51 | const FACTORY = 'Spreadsheet Editor'; 52 | 53 | /** 54 | * A widget factory for editors. 55 | */ 56 | export class SpreadsheetEditorFactory extends ABCWidgetFactory< 57 | IDocumentWidget, 58 | DocumentRegistry.ICodeModel 59 | > { 60 | /** 61 | * Create a new widget given a context. 62 | */ 63 | protected createNewWidget( 64 | context: DocumentRegistry.CodeContext 65 | ): IDocumentWidget { 66 | const content = new SpreadsheetWidget(context); 67 | return new SpreadsheetEditorDocumentWidget({ 68 | content, 69 | context, 70 | translator: this.translator 71 | }); 72 | } 73 | } 74 | 75 | /** 76 | * Add File Editor undo and redo widgets to the Edit menu 77 | */ 78 | export function addUndoRedoToEditMenu(menu: IMainMenu) { 79 | const isEnabled = (widget: Widget): boolean => { 80 | return ( 81 | widget instanceof DocumentWidget && 82 | widget.content instanceof SpreadsheetWidget 83 | ); 84 | }; 85 | menu.editMenu.undoers.undo.add({ 86 | id: CommandIDs.undo, 87 | isEnabled 88 | }); 89 | 90 | menu.editMenu.undoers.redo.add({ 91 | id: CommandIDs.redo, 92 | isEnabled 93 | }); 94 | } 95 | 96 | /** 97 | * Function to create a new untitled text file, given the current working directory. 98 | */ 99 | function createNew(commands: CommandRegistry, cwd: string, ext = 'tsv') { 100 | return commands 101 | .execute('docmanager:new-untitled', { 102 | path: cwd, 103 | type: 'file', 104 | ext 105 | }) 106 | .then(model => { 107 | return commands.execute('docmanager:open', { 108 | path: model.path, 109 | factory: FACTORY 110 | }); 111 | }); 112 | } 113 | 114 | /** 115 | * The command IDs used by the spreadsheet editor plugin. 116 | */ 117 | export namespace CommandIDs { 118 | export const createNewCSV = 'spreadsheet-editor:create-new-csv-file'; 119 | 120 | export const createNewTSV = 'spreadsheet-editor:create-new-tsv-file'; 121 | 122 | export const undo = 'spreadsheet-editor:undo'; 123 | 124 | export const redo = 'spreadsheet-editor:redo'; 125 | 126 | export const copy = 'spreadsheet-editor:copy'; 127 | 128 | export const paste = 'spreadsheet-editor:paste'; 129 | 130 | export const insertRowBelow = 'spreadsheet-editor:insert-row-below'; 131 | 132 | export const insertRowAbove = 'spreadsheet-editor:insert-row-above'; 133 | 134 | export const insertColumnLeft = 'spreadsheet-editor:insert-column-left'; 135 | 136 | export const insertColumnRight = 'spreadsheet-editor:insert-column-right'; 137 | 138 | export const removeColumn = 'spreadsheet-editor:remove-column'; 139 | 140 | export const removeRow = 'spreadsheet-editor:remove-row'; 141 | } 142 | 143 | /** 144 | * Add Create New DSV File to the Launcher 145 | */ 146 | export function addCreateNewToLauncher( 147 | launcher: ILauncher, 148 | trans: TranslationBundle 149 | ) { 150 | launcher.add({ 151 | command: CommandIDs.createNewCSV, 152 | category: trans.__('Other'), 153 | rank: 3 154 | }); 155 | launcher.add({ 156 | command: CommandIDs.createNewTSV, 157 | category: trans.__('Other'), 158 | rank: 3 159 | }); 160 | } 161 | 162 | /** 163 | * Add the New File command 164 | */ 165 | export function addCreateNewCommands( 166 | commands: CommandRegistry, 167 | contextMenuHitTest: ( 168 | fn: (node: HTMLElement) => boolean 169 | ) => HTMLElement | undefined, 170 | tracker: WidgetTracker>, 171 | browserFactory: IFileBrowserFactory, 172 | defaultBrowser: IDefaultFileBrowser, 173 | trans: TranslationBundle 174 | ) { 175 | const getJexcel = () => { 176 | return tracker.currentWidget?.content?.jexcel; 177 | }; 178 | 179 | commands.addCommand(CommandIDs.undo, { 180 | label: trans.__('Undo'), 181 | icon: undoIcon.bindprops({ stylesheet: 'menuItem' }), 182 | execute: () => { 183 | const jexcel = getJexcel(); 184 | if (!jexcel) { 185 | return; 186 | } 187 | jexcel.undo(); 188 | }, 189 | isEnabled: () => { 190 | const jexcel = getJexcel(); 191 | if (!jexcel) { 192 | return false; 193 | } 194 | return jexcel.history.length !== 0 && jexcel.historyIndex !== 0; 195 | } 196 | }); 197 | 198 | commands.addCommand(CommandIDs.redo, { 199 | label: trans.__('Redo'), 200 | icon: redoIcon.bindprops({ stylesheet: 'menuItem' }), 201 | execute: () => { 202 | const jexcel = getJexcel(); 203 | if (!jexcel) { 204 | return; 205 | } 206 | jexcel.redo(); 207 | }, 208 | isEnabled: () => { 209 | const jexcel = getJexcel(); 210 | if (!jexcel) { 211 | return false; 212 | } 213 | return ( 214 | jexcel.history.length !== 0 && 215 | jexcel.historyIndex !== jexcel.history.length - 1 216 | ); 217 | } 218 | }); 219 | 220 | commands.addCommand(CommandIDs.copy, { 221 | label: trans.__('Copy'), 222 | icon: copyIcon.bindprops({ stylesheet: 'menuItem' }), 223 | execute: () => { 224 | const jexcel = getJexcel(); 225 | if (!jexcel) { 226 | return; 227 | } 228 | jexcel.copy(true); 229 | }, 230 | isEnabled: () => !!getJexcel() 231 | }); 232 | 233 | commands.addCommand(CommandIDs.paste, { 234 | label: trans.__('Paste'), 235 | icon: pasteIcon.bindprops({ stylesheet: 'menuItem' }), 236 | execute: async () => { 237 | const jexcel = getJexcel(); 238 | if (!jexcel) { 239 | return; 240 | } 241 | const selection = jexcel.selectedCell; 242 | if (!selection) { 243 | return; 244 | } 245 | const text = await navigator.clipboard.readText(); 246 | if (text) { 247 | jexcel.paste(selection[0] as number, selection[1] as number, text); 248 | } 249 | }, 250 | isEnabled: () => !!getJexcel() 251 | }); 252 | 253 | commands.addCommand(CommandIDs.insertRowBelow, { 254 | label: trans.__('Insert Row Below'), 255 | icon: addBelowIcon.bindprops({ stylesheet: 'menuItem' }), 256 | execute: () => { 257 | const jexcel = getJexcel(); 258 | if (!jexcel) { 259 | return; 260 | } 261 | const cell = contextMenuHitTest(node => node.matches('td[data-y]')); 262 | const rowNumber = cell?.dataset.y 263 | ? parseInt(cell.dataset.y, 10) 264 | : undefined; 265 | jexcel.insertRow(1, rowNumber); 266 | }, 267 | isEnabled: () => !!getJexcel() 268 | }); 269 | 270 | commands.addCommand(CommandIDs.insertRowAbove, { 271 | label: trans.__('Insert Row Above'), 272 | icon: addAboveIcon.bindprops({ stylesheet: 'menuItem' }), 273 | execute: () => { 274 | const jexcel = getJexcel(); 275 | if (!jexcel) { 276 | return; 277 | } 278 | const cell = contextMenuHitTest(node => node.matches('td[data-y]')); 279 | const rowNumber = cell?.dataset.y 280 | ? parseInt(cell.dataset.y, 10) 281 | : undefined; 282 | // @ts-expect-error (wrong typing for insertBefore as `number`, should be `boolean`) 283 | jexcel.insertRow(1, rowNumber, true); 284 | }, 285 | isEnabled: () => !!getJexcel() 286 | }); 287 | 288 | commands.addCommand(CommandIDs.insertColumnLeft, { 289 | label: trans.__('Insert Column To The Left'), 290 | icon: addIcon.bindprops({ stylesheet: 'menuItem' }), 291 | execute: () => { 292 | const jexcel = getJexcel(); 293 | if (!jexcel) { 294 | return; 295 | } 296 | const cell = contextMenuHitTest(node => node.matches('td[data-x]')); 297 | const columnNumber = cell?.dataset.x 298 | ? parseInt(cell.dataset.x, 10) 299 | : undefined; 300 | jexcel.insertColumn(1, columnNumber, true); 301 | }, 302 | isEnabled: () => !!getJexcel() 303 | }); 304 | 305 | commands.addCommand(CommandIDs.insertColumnRight, { 306 | label: trans.__('Insert Column To The Right'), 307 | icon: addIcon.bindprops({ stylesheet: 'menuItem' }), 308 | execute: () => { 309 | const jexcel = getJexcel(); 310 | if (!jexcel) { 311 | return; 312 | } 313 | const cell = contextMenuHitTest(node => node.matches('td[data-x]')); 314 | const columnNumber = cell?.dataset.x 315 | ? parseInt(cell.dataset.x, 10) 316 | : undefined; 317 | jexcel.insertColumn(1, columnNumber, false); 318 | }, 319 | isEnabled: () => !!getJexcel() 320 | }); 321 | 322 | commands.addCommand(CommandIDs.removeColumn, { 323 | label: trans.__('Delete Column'), 324 | icon: removeColumnIcon.bindprops({ stylesheet: 'menuItem' }), 325 | execute: () => { 326 | const jexcel = getJexcel(); 327 | if (!jexcel) { 328 | return; 329 | } 330 | const cell = contextMenuHitTest(node => node.matches('td[data-x]')); 331 | if (!cell) { 332 | return; 333 | } 334 | const columnNumber = parseInt(cell.dataset.x!, 10); 335 | jexcel.deleteColumn(columnNumber); 336 | }, 337 | isEnabled: () => !!getJexcel() 338 | }); 339 | 340 | commands.addCommand(CommandIDs.removeRow, { 341 | label: trans.__('Delete Row'), 342 | icon: removeRowIcon.bindprops({ stylesheet: 'menuItem' }), 343 | execute: () => { 344 | const jexcel = getJexcel(); 345 | if (!jexcel) { 346 | return; 347 | } 348 | const cell = contextMenuHitTest(node => node.matches('td[data-y]')); 349 | if (!cell) { 350 | return; 351 | } 352 | const rowNumber = parseInt(cell.dataset.y!, 10); 353 | jexcel.deleteRow(rowNumber); 354 | }, 355 | isEnabled: () => !!getJexcel() 356 | }); 357 | 358 | commands.addCommand(CommandIDs.createNewCSV, { 359 | label: args => 360 | args['isPalette'] ? trans.__('New CSV File') : trans.__('CSV File'), 361 | caption: trans.__('Create a new CSV file'), 362 | icon: args => (args['isPalette'] ? undefined : spreadsheetIcon), 363 | execute: args => { 364 | const currentBrowser = 365 | browserFactory?.tracker.currentWidget ?? defaultBrowser; 366 | const cwd = args['cwd'] || currentBrowser.model.path; 367 | return createNew(commands, cwd as string, 'csv'); 368 | } 369 | }); 370 | 371 | commands.addCommand(CommandIDs.createNewTSV, { 372 | label: args => 373 | args['isPalette'] ? trans.__('New TSV File') : trans.__('TSV File'), 374 | caption: trans.__('Create a new TSV file'), 375 | icon: args => (args['isPalette'] ? undefined : spreadsheetIcon), 376 | execute: args => { 377 | const currentBrowser = 378 | browserFactory?.tracker.currentWidget ?? defaultBrowser; 379 | const cwd = args['cwd'] || currentBrowser.model.path; 380 | return createNew(commands, cwd as string, 'tsv'); 381 | } 382 | }); 383 | } 384 | 385 | const PLUGIN_ID = 'spreadsheet-editor:plugin'; 386 | 387 | /** 388 | * Initialization data for the spreadsheet-editor extension. 389 | */ 390 | const extension: JupyterFrontEndPlugin = { 391 | id: PLUGIN_ID, 392 | autoStart: true, 393 | requires: [IFileBrowserFactory, IDefaultFileBrowser], 394 | optional: [ 395 | ICommandPalette, 396 | ILauncher, 397 | IMainMenu, 398 | ILayoutRestorer, 399 | ISearchProviderRegistry, 400 | IStatusBar, 401 | ITranslator 402 | ], 403 | activate: ( 404 | app: JupyterFrontEnd, 405 | browserFactory: IFileBrowserFactory, 406 | defaultBrowser: IDefaultFileBrowser, 407 | palette: ICommandPalette | null, 408 | launcher: ILauncher | null, 409 | menu: IMainMenu | null, 410 | restorer: ILayoutRestorer | null, 411 | searchregistry: ISearchProviderRegistry | null, 412 | statusBar: IStatusBar | null, 413 | translator: ITranslator | null 414 | ) => { 415 | translator = translator || nullTranslator; 416 | const trans = translator.load(PLUGIN_ID); 417 | 418 | const factory = new SpreadsheetEditorFactory({ 419 | name: FACTORY, 420 | fileTypes: ['csv', 'tsv', '*'], 421 | defaultFor: ['csv', 'tsv'], 422 | translator: translator 423 | }); 424 | 425 | const tracker = new WidgetTracker>({ 426 | namespace: PLUGIN_ID 427 | }); 428 | 429 | if (restorer) { 430 | void restorer.restore(tracker, { 431 | command: 'docmanager:open', 432 | args: widget => ({ path: widget.context.path, factory: FACTORY }), 433 | name: widget => widget.context.path 434 | }); 435 | } 436 | 437 | app.docRegistry.addWidgetFactory(factory); 438 | const ft = app.docRegistry.getFileType('csv'); 439 | 440 | factory.widgetCreated.connect((sender, widget) => { 441 | // Track the widget. 442 | void tracker.add(widget); 443 | // Notify the widget tracker if restore data needs to update. 444 | widget.context.pathChanged.connect(() => { 445 | void tracker.save(widget); 446 | }); 447 | 448 | if (ft) { 449 | widget.title.icon = ft.icon!; 450 | widget.title.iconClass = ft.iconClass!; 451 | widget.title.iconLabel = ft.iconLabel!; 452 | } 453 | }); 454 | 455 | if (searchregistry) { 456 | searchregistry.add(PLUGIN_ID, new SpreadsheetSearchProviderFactory()); 457 | } 458 | 459 | addCreateNewCommands( 460 | app.commands, 461 | app.contextMenuHitTest.bind(app), 462 | tracker, 463 | browserFactory, 464 | defaultBrowser, 465 | trans 466 | ); 467 | 468 | if (palette) { 469 | palette.addItem({ 470 | command: CommandIDs.createNewCSV, 471 | args: { isPalette: true }, 472 | category: paletteCategory 473 | }); 474 | palette.addItem({ 475 | command: CommandIDs.createNewTSV, 476 | args: { isPalette: true }, 477 | category: paletteCategory 478 | }); 479 | } 480 | 481 | if (launcher) { 482 | addCreateNewToLauncher(launcher, trans); 483 | } 484 | 485 | if (menu) { 486 | addUndoRedoToEditMenu(menu); 487 | } 488 | 489 | if (statusBar) { 490 | const item = new SelectionStatus(translator); 491 | // Keep the status item up-to-date with the current spreadsheet editor. 492 | tracker.currentChanged.connect(() => { 493 | const current = tracker.currentWidget; 494 | item.model.spreadsheetWidget = current?.content ?? null; 495 | }); 496 | 497 | statusBar.registerStatusItem(PLUGIN_ID, { 498 | item, 499 | align: 'right', 500 | rank: 4, 501 | isActive: () => 502 | !!app.shell.currentWidget && 503 | !!tracker.currentWidget && 504 | app.shell.currentWidget === tracker.currentWidget 505 | }); 506 | } 507 | } 508 | }; 509 | 510 | export default extension; 511 | -------------------------------------------------------------------------------- /src/widget.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { Widget } from '@lumino/widgets'; 5 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 6 | import { PromiseDelegate, UUID } from '@lumino/coreutils'; 7 | import { PathExt } from '@jupyterlab/coreutils'; 8 | import Papa from 'papaparse'; 9 | import { Message } from '@lumino/messaging'; 10 | import jspreadsheet from 'jspreadsheet-ce'; 11 | import { Signal } from '@lumino/signaling'; 12 | import { ICellCoordinates } from './searchprovider'; 13 | 14 | type columnTypeId = 15 | | 'autocomplete' 16 | | 'calendar' 17 | | 'checkbox' 18 | | 'color' 19 | | 'dropdown' 20 | | 'hidden' 21 | | 'html' 22 | | 'image' 23 | | 'numeric' 24 | | 'radio' 25 | | 'text'; 26 | 27 | export interface ISelection { 28 | rows: number; 29 | columns: number; 30 | } 31 | 32 | /** 33 | * An spreadsheet widget. 34 | */ 35 | export class SpreadsheetWidget extends Widget { 36 | public jexcel: jspreadsheet.JspreadsheetInstance | null = null; 37 | protected separator: string; 38 | protected linebreak: string = '\n'; 39 | public fitMode: 'all-equal-default' | 'all-equal-fit' | 'fit-cells'; 40 | public changed: Signal; 41 | protected hasFrozenColumns: boolean; 42 | private editor: HTMLDivElement; 43 | private container: HTMLDivElement; 44 | private columnTypesBar: HTMLDivElement; 45 | private selectAllElement: HTMLElement | null = null; 46 | 47 | protected firstRowAsHeader: boolean; 48 | private header: Array | undefined = undefined; 49 | private columnTypes: Array = []; 50 | public selectionChanged: Signal; 51 | 52 | /** 53 | * Construct a new Spreadsheet widget. 54 | */ 55 | constructor(context: DocumentRegistry.CodeContext) { 56 | super(); 57 | this.id = UUID.uuid4(); 58 | this.title.label = PathExt.basename(context.localPath); 59 | this.title.closable = true; 60 | this.context = context; 61 | this.separator = ''; // Papa auto detect 62 | this.fitMode = 'all-equal-default'; 63 | if (context.localPath.endsWith('tsv')) { 64 | this.separator = '\t'; 65 | } 66 | if (context.localPath.endsWith('csv')) { 67 | this.separator = ','; 68 | } 69 | this.hasFrozenColumns = false; 70 | this.firstRowAsHeader = false; 71 | 72 | context.ready 73 | .then(() => { 74 | this._onContextReady(); 75 | }) 76 | .catch(console.warn); 77 | this.changed = new Signal(this); 78 | this._selection = { 79 | rows: 0, 80 | columns: 0 81 | }; 82 | this.selectionChanged = new Signal(this); 83 | 84 | const container = document.createElement('div'); 85 | container.className = 'se-area-container'; 86 | this.node.appendChild(container); 87 | 88 | // TODO: move to a separate class/widget 89 | this.columnTypesBar = document.createElement('div'); 90 | this.columnTypesBar.classList.add('se-column-types'); 91 | this.columnTypesBar.classList.add('se-hidden'); 92 | this.columnTypeSelectors = new Map(); 93 | container.appendChild(this.columnTypesBar); 94 | 95 | this.editor = document.createElement('div'); 96 | container.appendChild(this.editor); 97 | this.container = container; 98 | } 99 | 100 | protected parseValue(content: string): jspreadsheet.CellValue[][] { 101 | const parsed = Papa.parse(content, { delimiter: this.separator }); 102 | if (!this.separator) { 103 | this.separator = parsed.meta.delimiter; 104 | } 105 | this.linebreak = parsed.meta.linebreak; 106 | if (parsed.errors.length) { 107 | console.warn('Parsing errors encountered', parsed.errors); 108 | } 109 | const columnsNumber = this.extractColumnNumber(parsed.data); 110 | // TODO: read the actual type from a file? 111 | // TODO only redefine if reading for the first time? 112 | // TODO add/remove when column added removed? 113 | if (typeof this.columnTypes === 'undefined') { 114 | this.columnTypes = [ 115 | ...Array(columnsNumber).map(() => 'text' as columnTypeId) 116 | ]; 117 | } 118 | 119 | if (this.firstRowAsHeader) { 120 | this.header = parsed.data.shift(); 121 | } else { 122 | this.header = undefined; 123 | } 124 | 125 | return parsed.data; 126 | } 127 | 128 | extractColumnNumber(data: jspreadsheet.CellValue[][]): number { 129 | return data.length ? data[0].length : 0; 130 | } 131 | 132 | columns(columnsNumber: number) { 133 | const columns: Array = []; 134 | 135 | for (let i = 0; i < columnsNumber; i++) { 136 | columns.push({ 137 | title: this.header ? this.header[i] : undefined, 138 | type: this.columnTypes[i] 139 | }); 140 | } 141 | return columns; 142 | } 143 | 144 | private onChange(): void { 145 | this.context.model.sharedModel.setSource(this.getValue()); 146 | this.changed.emit(); 147 | } 148 | 149 | public get selection(): ISelection { 150 | return this._selection; 151 | } 152 | 153 | private _selection: ISelection; 154 | 155 | private _onContextReady(): void { 156 | if (this.isDisposed) { 157 | return; 158 | } 159 | const contextModel = this.context.model; 160 | 161 | // Set the editor model value. 162 | const content = contextModel.toString(); 163 | const data = this.parseValue(content); 164 | 165 | const options: jspreadsheet.JSpreadsheetOptions = { 166 | data: data, 167 | // Disable export since its shortcut conflicts with JupyterLab's default save shortcut 168 | allowExport: false, 169 | minDimensions: [1, 1], 170 | // minSpareCols: 1, 171 | // minSpareRows: 1, 172 | csvFileName: this.title.label, 173 | // @ts-expect-error (boolean missing in typing, but documented in repo) 174 | contextMenu: false, 175 | columnDrag: true, 176 | onchange: () => { 177 | this.onChange(); 178 | }, 179 | // insert 180 | oninsertrow: () => { 181 | this.onChange(); 182 | }, 183 | oninsertcolumn: () => { 184 | this.onChange(); 185 | this.populateColumnTypesBar(); 186 | this.onResize(); 187 | }, 188 | // move 189 | onmoverow: () => { 190 | this.onChange(); 191 | }, 192 | onmovecolumn: () => { 193 | this.onChange(); 194 | }, 195 | // delete 196 | ondeleterow: () => { 197 | this.onChange(); 198 | }, 199 | ondeletecolumn: () => { 200 | this.onChange(); 201 | this.populateColumnTypesBar(); 202 | this.onResize(); 203 | }, 204 | // resize 205 | onresizecolumn: () => { 206 | this.adjustColumnTypesWidth(); 207 | }, 208 | onselection: ( 209 | el: HTMLElement, 210 | borderLeft: number, 211 | borderTop: number, 212 | borderRight: number, 213 | borderBottom: number, 214 | origin: any 215 | ) => { 216 | this._selection = { 217 | rows: borderBottom - borderTop + 1, 218 | columns: borderRight - borderLeft + 1 219 | }; 220 | this.selectionChanged.emit(this._selection); 221 | // TODO: support all corners of selection 222 | this.scrollCellIntoView({ column: borderLeft, row: borderTop }); 223 | }, 224 | columns: this.columns(this.extractColumnNumber(data)) 225 | }; 226 | 227 | this.createEditor(options); 228 | 229 | // Wire signal connections. 230 | contextModel.contentChanged.connect(this._onContentChanged, this); 231 | 232 | this.populateColumnTypesBar(); 233 | 234 | // If the sheet is not too big, use the more user-friendly columns width adjustment model 235 | if (data.length && data[0].length * data.length < 100 * 100) { 236 | this.fitMode = 'fit-cells'; 237 | this.relayout(); 238 | } 239 | 240 | // Resolve the ready promise. 241 | this._ready.resolve(undefined); 242 | } 243 | 244 | reloadEditor(options: jspreadsheet.JSpreadsheetOptions) { 245 | const config = this.jexcel!.getConfig(); 246 | this.jexcel!.destroy(); 247 | this.createEditor({ 248 | ...config, 249 | ...options 250 | }); 251 | this.relayout(); 252 | } 253 | 254 | switchHeaders() { 255 | const value = this.getValue(); 256 | this.firstRowAsHeader = !this.firstRowAsHeader; 257 | const data = this.parseValue(value); 258 | this.reloadEditor({ 259 | data: data, 260 | columns: this.columns(this.extractColumnNumber(data)) 261 | }); 262 | } 263 | 264 | switchTypesBar() { 265 | this.columnTypesBar.classList.toggle('se-hidden'); 266 | } 267 | 268 | columnTypeSelectors: Map; 269 | 270 | protected populateColumnTypesBar() { 271 | // TODO: interface with name, id, options callback? 272 | const options = [ 273 | 'text', 274 | 'numeric', 275 | 'hidden', 276 | 'dropdown', 277 | 'autocomplete', 278 | 'checkbox', 279 | 'radio', 280 | 'calendar', 281 | 'image', 282 | 'color', 283 | 'html' 284 | ]; 285 | this.columnTypesBar.innerHTML = ''; 286 | this.columnTypeSelectors.clear(); 287 | for (let columnId = 0; columnId < this.columnsNumber; columnId++) { 288 | // TODO react widget 289 | const columnTypeSelector = document.createElement('select'); 290 | for (const option of options) { 291 | const optionElement = document.createElement('option'); 292 | optionElement.value = option; 293 | optionElement.text = option; 294 | columnTypeSelector.appendChild(optionElement); 295 | } 296 | columnTypeSelector.onchange = ev => { 297 | const select = ev.target as HTMLSelectElement; 298 | this.columnTypes[columnId] = select.options[select.selectedIndex] 299 | .value as columnTypeId; 300 | this.reloadEditor({ columns: this.columns(this.columnTypes.length) }); 301 | }; 302 | this.columnTypeSelectors.set(columnId, columnTypeSelector); 303 | this.columnTypesBar.appendChild(columnTypeSelector); 304 | } 305 | } 306 | 307 | protected adjustColumnTypesWidth() { 308 | if (!this.selectAllElement || this.columnTypeSelectors.size === 0) { 309 | return; 310 | } 311 | this.columnTypesBar.style.marginLeft = 312 | this.selectAllElement.offsetWidth + 'px'; 313 | const widths = this.jexcel!.getWidth(undefined); 314 | for (let columnId = 0; columnId < this.columnsNumber; columnId++) { 315 | this.columnTypeSelectors.get(columnId)!.style.width = 316 | widths[columnId] + 'px'; 317 | } 318 | } 319 | 320 | protected createEditor(options: jspreadsheet.JSpreadsheetOptions) { 321 | this.jexcel = jspreadsheet(this.editor, options); 322 | this.selectAllElement = 323 | this.jexcel.headerContainer.querySelector('.jexcel_selectall'); 324 | } 325 | 326 | protected onAfterShow(msg: Message) { 327 | super.onAfterShow(msg); 328 | this.relayout(); 329 | } 330 | 331 | get ready(): Promise { 332 | return this._ready.promise; 333 | } 334 | 335 | getValue(): string { 336 | const data = this.jexcel!.getData(); 337 | if (this.firstRowAsHeader) { 338 | data.unshift(this.jexcel!.getHeaders(true) as string[]); 339 | } 340 | return Papa.unparse(data, { 341 | delimiter: this.separator, 342 | newline: this.linebreak 343 | }); 344 | } 345 | 346 | setValue(value: string) { 347 | const parsed = this.parseValue(value); 348 | this.jexcel!.setData(parsed); 349 | } 350 | 351 | private _onContentChanged(): void { 352 | const oldValue = this.getValue(); 353 | const newValue = this.context.model.toString(); 354 | 355 | if (oldValue !== newValue) { 356 | this.setValue(newValue); 357 | } 358 | } 359 | 360 | get wrapper() { 361 | if (this.hasFrozenColumns) { 362 | return this.jexcel!.content; 363 | } 364 | return this.container; 365 | } 366 | 367 | onResize() { 368 | if (!this.jexcel) { 369 | return; 370 | } 371 | if (this.fitMode === 'all-equal-fit') { 372 | this.relayout(); 373 | } 374 | if (this.hasFrozenColumns) { 375 | this.jexcel.content.style.width = this.node.offsetWidth + 'px'; 376 | this.jexcel.content.style.height = 377 | (this.node.querySelector('.jexcel_content') as HTMLElement) 378 | .offsetHeight + 'px'; 379 | } 380 | this.adjustColumnTypesWidth(); 381 | } 382 | 383 | protected onActivateRequest(msg: Message): void { 384 | // ensure focus 385 | // TODO 386 | // this.jexcel.el.focus(); 387 | this.editor.focus(); 388 | } 389 | 390 | dispose(): void { 391 | if (this.jexcel) { 392 | this.jexcel.destroy(); 393 | } 394 | super.dispose(); 395 | } 396 | 397 | updateModel() { 398 | this.context.model.sharedModel.setSource(this.getValue()); 399 | } 400 | 401 | freezeSelectedColumns() { 402 | const columns = this.jexcel!.getSelectedColumns(); 403 | this.reloadEditor({ 404 | freezeColumns: Math.max(...columns) + 1, 405 | tableOverflow: true, 406 | tableWidth: this.node.offsetWidth + 'px', 407 | tableHeight: this.node.offsetHeight + 'px' 408 | }); 409 | this.hasFrozenColumns = true; 410 | } 411 | 412 | unfreezeColumns() { 413 | this.reloadEditor({ 414 | freezeColumns: undefined, 415 | tableOverflow: false, 416 | tableWidth: undefined, 417 | tableHeight: undefined 418 | }); 419 | this.hasFrozenColumns = false; 420 | } 421 | 422 | get columnsNumber(): number { 423 | const data = this.jexcel!.getData(); 424 | if (!data.length) { 425 | return 0; 426 | } 427 | return data[0].length; 428 | } 429 | 430 | getHeaderElements() { 431 | const headers = []; 432 | for (const element of this.jexcel!.headerContainer.children) { 433 | // TODO use data attribute? 434 | if (element.className !== 'jexcel_selectall') { 435 | headers.push(element); 436 | } 437 | } 438 | return headers; 439 | } 440 | 441 | relayout() { 442 | if (!this.jexcel) { 443 | return; 444 | } 445 | const columns = this.columnsNumber; 446 | 447 | if (!columns) { 448 | return; 449 | } 450 | 451 | switch (this.fitMode) { 452 | case 'all-equal-default': { 453 | const options = this.jexcel.getConfig(); 454 | for (let i = 0; i < columns; i++) { 455 | this.jexcel.setWidth(i, options.defaultColWidth, undefined); 456 | } 457 | break; 458 | } 459 | case 'all-equal-fit': { 460 | const indexColumn = this.node.querySelector( 461 | '.jexcel_selectall' 462 | ) as HTMLElement; 463 | const availableWidth = this.node.clientWidth - indexColumn.offsetWidth; 464 | const widthPerColumn = availableWidth / columns; 465 | for (let i = 0; i < columns; i++) { 466 | this.jexcel.setWidth(i, widthPerColumn, undefined); 467 | } 468 | break; 469 | } 470 | case 'fit-cells': { 471 | const data = this.jexcel.getData(); 472 | const headers = this.getHeaderElements(); 473 | for (let i = 0; i < columns; i++) { 474 | let maxColumnWidth = Math.max(25, headers[i].scrollWidth); 475 | for (let j = 0; j < data.length; j++) { 476 | const cell = this.jexcel.getCellFromCoords(i, j) as HTMLElement; 477 | maxColumnWidth = Math.max(maxColumnWidth, cell.scrollWidth); 478 | } 479 | this.jexcel.setWidth(i, maxColumnWidth, undefined); 480 | } 481 | break; 482 | } 483 | } 484 | this.adjustColumnTypesWidth(); 485 | } 486 | 487 | context: DocumentRegistry.CodeContext; 488 | private _ready = new PromiseDelegate(); 489 | 490 | scrollCellIntoView(match: ICellCoordinates) { 491 | const cell = this.jexcel!.getCellFromCoords(match.column, match.row); 492 | const cellRect = cell.getBoundingClientRect(); 493 | const wrapperRect = this.wrapper.getBoundingClientRect(); 494 | let alignToTop = false; 495 | const softMargin = 3; 496 | 497 | if (cellRect.right > wrapperRect.right) { 498 | this.wrapper.scrollBy(cellRect.right - wrapperRect.right, 0); 499 | } else if (cellRect.left < wrapperRect.left) { 500 | this.wrapper.scrollBy(cellRect.left - wrapperRect.left, 0); 501 | } 502 | 503 | if (cellRect.top - softMargin < wrapperRect.top) { 504 | alignToTop = true; 505 | } 506 | if (alignToTop || cellRect.bottom + softMargin > wrapperRect.bottom) { 507 | cell.scrollIntoView(alignToTop); 508 | } 509 | } 510 | } 511 | --------------------------------------------------------------------------------