├── .husky ├── .gitignore └── pre-commit ├── .eslintignore ├── binder ├── jupyter_config.json ├── environment.yml └── postBuild ├── .prettierignore ├── docs ├── images │ ├── md.png │ ├── latex.png │ ├── panel.png │ ├── header.png │ ├── rename.png │ └── init_comment.png ├── requirements.txt ├── installation.md ├── developers.md ├── users │ ├── users.rst │ ├── features.rst │ └── usage.md ├── introduction.md ├── Makefile ├── make.bat ├── index.rst └── conf.py ├── .prettierrc ├── style ├── index.js ├── index.css ├── base.css ├── icons │ ├── user-icon-19.svg │ ├── user-icon-4.svg │ ├── user-icon-12.svg │ ├── user-icon-9.svg │ ├── user-icon-3.svg │ ├── user-icon-18.svg │ ├── user-icon-1.svg │ ├── user-icon-22.svg │ ├── user-icon-7.svg │ ├── user-icon-15.svg │ ├── user-icon-16.svg │ ├── user-icon-6.svg │ ├── user-icon-17.svg │ ├── user-icon-8.svg │ ├── user-icon-10.svg │ ├── user-icon-0.svg │ ├── user-icon-21.svg │ ├── user-icon-13.svg │ ├── user-icon-14.svg │ ├── user-icon-11.svg │ ├── user-icon-5.svg │ ├── user-icon-20.svg │ ├── user-icon-23.svg │ └── user-icon-2.svg ├── panelHeader.css ├── reply.css └── comment.css ├── src ├── api │ ├── svg.d.ts │ ├── index.ts │ ├── commentformat.ts │ ├── registry.ts │ ├── factory.ts │ ├── token.ts │ ├── button.ts │ ├── utils.ts │ ├── icons.ts │ ├── header.tsx │ ├── plugin.ts │ ├── panel.ts │ └── model.ts ├── text │ ├── index.ts │ ├── commentformat.ts │ ├── commentfactory.ts │ ├── utils.ts │ ├── widgetfactory.ts │ ├── widget.ts │ └── plugin.ts ├── notebook │ ├── index.ts │ ├── commentformat.ts │ ├── commentfactory.ts │ ├── utils.ts │ ├── widgetfactory.ts │ ├── widget.ts │ └── plugin.ts └── index.ts ├── install.json ├── webpack_config.js ├── jupyterlab_comments ├── __init__.py └── _version.py ├── .readthedocs.yaml ├── tsconfig.json ├── pyproject.toml ├── lint-staged.config.js ├── MANIFEST.in ├── LICENSE ├── .gitignore ├── Press_Release.md ├── setup.py ├── README.md ├── .eslintrc.js ├── .github └── workflows │ └── build.yml ├── demo ├── hh.py └── hh_demo.ipynb └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | **/*.d.ts 5 | tests 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | jlpm lint-staged 5 | -------------------------------------------------------------------------------- /binder/jupyter_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LabApp": { 3 | "collaborative": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | jupyterlab_comments 6 | -------------------------------------------------------------------------------- /docs/images/md.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupytercalpoly/jupyterlab-comments/HEAD/docs/images/md.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /docs/images/latex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupytercalpoly/jupyterlab-comments/HEAD/docs/images/latex.png -------------------------------------------------------------------------------- /docs/images/panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupytercalpoly/jupyterlab-comments/HEAD/docs/images/panel.png -------------------------------------------------------------------------------- /docs/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupytercalpoly/jupyterlab-comments/HEAD/docs/images/header.png -------------------------------------------------------------------------------- /docs/images/rename.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupytercalpoly/jupyterlab-comments/HEAD/docs/images/rename.png -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | import './panelHeader.css'; 3 | import './comment.css' 4 | import './reply.css' 5 | -------------------------------------------------------------------------------- /docs/images/init_comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupytercalpoly/jupyterlab-comments/HEAD/docs/images/init_comment.png -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | @import url('panelHeader.css'); 3 | @import url('comment.css'); 4 | @import url('reply.css'); 5 | -------------------------------------------------------------------------------- /src/api/svg.d.ts: -------------------------------------------------------------------------------- 1 | // Necessary for the Custom Jupyterlab Icons 2 | declare module '*.svg' { 3 | const value: string; 4 | export default value; 5 | } 6 | -------------------------------------------------------------------------------- /src/text/index.ts: -------------------------------------------------------------------------------- 1 | export * from './commentfactory'; 2 | export * from './commentformat'; 3 | export * from './plugin'; 4 | export * from './utils'; 5 | export * from './widget'; 6 | export * from './widgetfactory'; 7 | -------------------------------------------------------------------------------- /src/notebook/index.ts: -------------------------------------------------------------------------------- 1 | export * from './commentfactory'; 2 | export * from './commentformat'; 3 | export * from './plugin'; 4 | export * from './utils'; 5 | export * from './widget'; 6 | export * from './widgetfactory'; 7 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyterlab_comments", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyterlab_comments" 5 | } 6 | -------------------------------------------------------------------------------- /webpack_config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [ 4 | { 5 | test: /\.m?js/, 6 | resolve: { 7 | fullySpecified: false 8 | } 9 | } 10 | ] 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | myst-parser==0.15.1 2 | Sphinx==4.1.2 3 | sphinx-rtd-theme==0.5.2 4 | sphinxcontrib-applehelp==1.0.2 5 | sphinxcontrib-devhelp==1.0.2 6 | sphinxcontrib-htmlhelp==2.0.0 7 | sphinxcontrib-jsmath==1.0.1 8 | sphinxcontrib-qthelp==1.0.3 9 | sphinxcontrib-serializinghtml==1.1.5 -------------------------------------------------------------------------------- /style/base.css: -------------------------------------------------------------------------------- 1 | .jc-Highlight { 2 | background-color: rgba(255, 165, 0, 0.25); 3 | } 4 | 5 | .jc-HighlightFocus { 6 | background-color: rgba(255, 165, 0, 0.65); 7 | } 8 | 9 | .jc-IconShadow { 10 | filter: drop-shadow(0px 2px 1px var(--jp-shadow-umbra-color)); 11 | cursor: pointer; 12 | } -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation Guide 2 | 3 | ## Requirements 4 | 5 | JupyterLab >= 3.1.6 6 | 7 | ## Install and Use 8 | 9 | ``` 10 | pip install jupyterlab-comments 11 | jupyter lab --collaborative 12 | ``` 13 | 14 | ## Uninstall 15 | 16 | ``` 17 | pip uninstall jupyterlab-comments 18 | ``` 19 | -------------------------------------------------------------------------------- /src/text/commentformat.ts: -------------------------------------------------------------------------------- 1 | import { IComment } from '../api'; 2 | import { CodeEditor } from '@jupyterlab/codeeditor'; 3 | 4 | export interface ITextSelectionComment extends IComment { 5 | type: 'text-selection'; 6 | target: { 7 | start: CodeEditor.IPosition; 8 | end: CodeEditor.IPosition; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /docs/developers.md: -------------------------------------------------------------------------------- 1 | # Developers 2 | 3 | **A developer's introduction to JupyterLab Comments** 4 | 5 | Developers can see the `/src/api` folder as a guide to develop comments for their own applications. 6 | 7 | Examples of comment types are shown in `src/notebook` for Notebook-Level comments and `src/text` for Text-Level comments 8 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button'; 2 | export * from './commentformat'; 3 | export * from './factory'; 4 | export * from './icons'; 5 | export * from './model'; 6 | export * from './panel'; 7 | export * from './plugin'; 8 | export * from './header'; 9 | export * from './registry'; 10 | export * from './utils'; 11 | export * from './token'; 12 | export * from './widget'; 13 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | name: jupyterlab-comments-demo 2 | 3 | channels: 4 | - conda-forge/label/jupyterlab_rc 5 | - conda-forge 6 | 7 | dependencies: 8 | # runtime 9 | - python >=3.8,<3.9.0a0 10 | - jupyterlab >=3.1.0rc1,<4.0.0a0 11 | - numpy 12 | - scipy 13 | - matplotlib 14 | # labextension build 15 | - nodejs >=15,<16 16 | - pip 17 | - wheel 18 | # extensions 19 | - jupyterlab-link-share -------------------------------------------------------------------------------- /jupyterlab_comments/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | from pathlib import Path 4 | 5 | from ._version import __version__ 6 | 7 | HERE = Path(__file__).parent.resolve() 8 | 9 | with (HERE / "labextension" / "package.json").open() as fid: 10 | data = json.load(fid) 11 | 12 | def _jupyter_labextension_paths(): 13 | return [{ 14 | "src": "labextension", 15 | "dest": data["name"] 16 | }] 17 | 18 | -------------------------------------------------------------------------------- /docs/users/users.rst: -------------------------------------------------------------------------------- 1 | Users 2 | ======= 3 | 4 | **A User's introduction to JupyterLab Comments** 5 | 6 | Layout 7 | ~~~~~~~~~ 8 | 9 | Comments can be accessed by clicking on the comment icon on the right panel. Doing so will open up the main ``Comment Panel``. 10 | 11 | .. image:: ../images/panel.png 12 | :width: 200 13 | 14 | Use Cases and Features 15 | ~~~~~~~~~~~~~~~~~~~~~~~~ 16 | 17 | .. toctree:: 18 | usage.md 19 | features.rst 20 | :maxdepth: 1 21 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Jupyterlab Comments aims to create a medium for communication in the latest release of JupyterLab (3.x.x). 4 | 5 | The headlining feature in the newest release of JupyterLab is RTC (Real Time Collaboration) which uses the CRDT by Y.js to enable synchronous/real time capabilities. Our extension `jupyterlab-comments` leverages these capabilities to create a collaborative environment where users can comment on files that they work on in JupyterLab. 6 | -------------------------------------------------------------------------------- /src/notebook/commentformat.ts: -------------------------------------------------------------------------------- 1 | import { IComment } from '../api'; 2 | import { CodeEditor } from '@jupyterlab/codeeditor'; 3 | 4 | export interface ICellComment extends IComment { 5 | type: 'cell'; 6 | target: { 7 | cellID: string; 8 | }; 9 | } 10 | 11 | export interface ICellSelectionComment extends IComment { 12 | type: 'cell-selection'; 13 | target: { 14 | cellID: string; 15 | start: CodeEditor.IPosition; 16 | end: CodeEditor.IPosition; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /jupyterlab_comments/_version.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | __all__ = ["__version__"] 5 | 6 | def _fetchVersion(): 7 | HERE = Path(__file__).parent.resolve() 8 | 9 | for settings in HERE.rglob("package.json"): 10 | try: 11 | with settings.open() as f: 12 | return json.load(f)["version"] 13 | except FileNotFoundError: 14 | pass 15 | 16 | raise FileNotFoundError(f"Could not find package.json under dir {HERE!s}") 17 | 18 | __version__ = _fetchVersion() 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF 13 | formats: 14 | - pdf 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: "3.7" 19 | install: 20 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /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": "es2017", 21 | "types": [] 22 | }, 23 | "include": ["src/**/*"] 24 | } 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["jupyter_packaging~=0.10,<2", "jupyterlab~=3.0"] 3 | build-backend = "jupyter_packaging.build_api" 4 | 5 | [tool.jupyter-packaging.options] 6 | skip-if-exists = ["jupyterlab_comments/labextension/static/style.js"] 7 | ensured-targets = ["jupyterlab_comments/labextension/static/style.js", "jupyterlab_comments/labextension/package.json"] 8 | 9 | [tool.jupyter-packaging.builder] 10 | factory = "jupyter_packaging.npm_builder" 11 | 12 | [tool.jupyter-packaging.build-args] 13 | build_cmd = "build:prod" 14 | npm = ["jlpm"] 15 | 16 | [tool.check-manifest] 17 | ignore = ["jupyterlab_comments/labextension/**", "yarn.lock", ".*", "package-lock.json"] 18 | -------------------------------------------------------------------------------- /style/icons/user-icon-19.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /style/icons/user-icon-12.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-9.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-18.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | commentRegistryPlugin, 3 | commentWidgetRegistryPlugin, 4 | jupyterCommentingPlugin 5 | } from './api'; 6 | // Importing directly from './text' causes the imported plugin to be undefined (??) 7 | import { textCommentingPlugin } from './text/plugin'; 8 | import { notebookCommentsPlugin } from './notebook'; 9 | import { JupyterFrontEndPlugin } from '@jupyterlab/application'; 10 | 11 | export * from './api'; 12 | export * from './notebook'; 13 | export * from './text'; 14 | 15 | const plugins: JupyterFrontEndPlugin[] = [ 16 | jupyterCommentingPlugin, 17 | commentRegistryPlugin, 18 | commentWidgetRegistryPlugin, 19 | notebookCommentsPlugin, 20 | textCommentingPlugin 21 | ]; 22 | 23 | /** 24 | * Export the plugins as default. 25 | */ 26 | export default plugins; 27 | -------------------------------------------------------------------------------- /src/api/commentformat.ts: -------------------------------------------------------------------------------- 1 | import { PartialJSONObject, PartialJSONValue } from '@lumino/coreutils'; 2 | 3 | /** 4 | * A type for the identity of a commenter. 5 | */ 6 | export interface IIdentity extends PartialJSONObject { 7 | id: number; 8 | name: string; 9 | color: string; 10 | icon: number; 11 | } 12 | 13 | export interface IBaseComment extends PartialJSONObject { 14 | id: string; 15 | type: string; 16 | identity: IIdentity; 17 | text: string; 18 | time: string; 19 | editedTime?: string; 20 | } 21 | 22 | export interface IReply extends IBaseComment { 23 | type: 'reply'; 24 | } 25 | 26 | export interface ICommentWithReplies extends IBaseComment { 27 | replies: IReply[]; 28 | } 29 | 30 | export interface IComment extends ICommentWithReplies { 31 | target: PartialJSONValue; 32 | } 33 | -------------------------------------------------------------------------------- /style/icons/user-icon-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-22.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-15.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-16.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-17.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-8.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/text/commentfactory.ts: -------------------------------------------------------------------------------- 1 | import { ITextSelectionComment } from './commentformat'; 2 | import { CommentFactory } from '../api'; 3 | import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; 4 | 5 | export class TextSelectionCommentFactory extends CommentFactory { 6 | createComment( 7 | options: CommentFactory.ICommentOptions 8 | ): ITextSelectionComment { 9 | const comment = super.createComment(options); 10 | const wrapper = options.source; 11 | 12 | let { start, end } = wrapper.editor.getSelection(); 13 | 14 | if ( 15 | start.line > end.line || 16 | (start.line === end.line && start.column > end.column) 17 | ) { 18 | [start, end] = [end, start]; 19 | } 20 | 21 | comment.target = { start, end }; 22 | 23 | return comment; 24 | } 25 | 26 | readonly type = 'text-selection'; 27 | } 28 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | const escape = require('shell-quote').quote; 2 | const fs = require('fs'); 3 | const isWin = process.platform === 'win32'; 4 | 5 | const escapeFileNames = filenames => 6 | filenames 7 | .filter(filename => fs.existsSync(filename)) 8 | .map(filename => `"${isWin ? filename : escape([filename])}"`) 9 | .join(' '); 10 | 11 | module.exports = { 12 | '**/*{.css,.json,.md}': filenames => { 13 | const escapedFileNames = escapeFileNames(filenames); 14 | return [ 15 | `prettier --write ${escapedFileNames}`, 16 | `git add -f ${escapedFileNames}` 17 | ]; 18 | }, 19 | '**/*{.ts,.tsx,.js,.jsx}': filenames => { 20 | const escapedFileNames = escapeFileNames(filenames); 21 | return [ 22 | `prettier --write ${escapedFileNames}`, 23 | `eslint --fix ${escapedFileNames}`, 24 | `git add -f ${escapedFileNames}` 25 | ]; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /style/icons/user-icon-10.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-0.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-21.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-13.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. JupyterLab Comments documentation master file, created by 2 | sphinx-quickstart on Thu Aug 19 14:28:24 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | =============================================== 7 | JupyterLab Comments's documentation 8 | =============================================== 9 | 10 | ----------------------------------------------- 11 | *Open up communication in JupyterLab* 12 | ----------------------------------------------- 13 | 14 | 15 | WIP for now! 16 | 17 | .. toctree:: 18 | introduction.md 19 | installation.md 20 | :maxdepth: 1 21 | :caption: Getting Started: 22 | 23 | .. toctree:: 24 | users/users.rst 25 | developers.md 26 | :maxdepth: 1 27 | :caption: Pathways: 28 | 29 | .. toctree:: 30 | users/features.rst 31 | users/usage.md 32 | :maxdepth: 1 33 | :caption: Users 34 | 35 | Indices and tables 36 | ================== 37 | 38 | * :ref:`genindex` 39 | * :ref:`modindex` 40 | * :ref:`search` 41 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include pyproject.toml 4 | recursive-include jupyter-config *.json 5 | 6 | include package.json 7 | include install.json 8 | include ts*.json 9 | include yarn.lock 10 | 11 | exclude lint-staged.config.js 12 | exclude Press_Release.md 13 | exclude jupyter_config.json 14 | exclude webpack_config.js 15 | 16 | graft jupyterlab_comments/labextension 17 | 18 | # Javascript files 19 | graft src 20 | graft style 21 | prune **/node_modules 22 | prune lib 23 | prune binder 24 | prune .husky 25 | 26 | # Patterns to exclude from any directory 27 | global-exclude *~ 28 | global-exclude *.pyc 29 | global-exclude *.pyo 30 | global-exclude .git 31 | global-exclude .ipynb_checkpoints 32 | 33 | # Read the docs 34 | recursive-include docs *.bat 35 | recursive-include docs *.md 36 | recursive-include docs *.py 37 | recursive-include docs *.rst 38 | recursive-include docs *.txt 39 | recursive-include docs *.png 40 | recursive-include docs Makefile 41 | 42 | # demo folder 43 | recursive-include demo *.ipynb 44 | recursive-include demo *.py 45 | -------------------------------------------------------------------------------- /style/icons/user-icon-14.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-11.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/notebook/commentfactory.ts: -------------------------------------------------------------------------------- 1 | import { CommentFactory } from '../api'; 2 | import { ICellComment, ICellSelectionComment } from './commentformat'; 3 | import { Cell } from '@jupyterlab/cells'; 4 | 5 | export class CellCommentFactory extends CommentFactory { 6 | createComment(options: CommentFactory.ICommentOptions): ICellComment { 7 | const comment = super.createComment(options); 8 | comment.target = { cellID: options.source.model.id }; 9 | 10 | return comment; 11 | } 12 | 13 | readonly type = 'cell'; 14 | } 15 | 16 | export class CellSelectionCommentFactory extends CommentFactory { 17 | createComment( 18 | options: CommentFactory.ICommentOptions 19 | ): ICellSelectionComment { 20 | const comment = super.createComment(options); 21 | const { start, end } = options.source.editor.getSelection(); 22 | 23 | comment.target = { 24 | cellID: options.source.model.id, 25 | start, 26 | end 27 | }; 28 | 29 | return comment; 30 | } 31 | 32 | readonly type = 'cell-selection'; 33 | } 34 | -------------------------------------------------------------------------------- /src/api/registry.ts: -------------------------------------------------------------------------------- 1 | import { CommentFactory, CommentWidgetFactory } from './factory'; 2 | import { ICommentRegistry, ICommentWidgetRegistry } from './token'; 3 | 4 | /** 5 | * A class that manages a map of `CommentFactory`s 6 | */ 7 | export class CommentRegistry implements ICommentRegistry { 8 | addFactory(factory: CommentFactory): void { 9 | this.factories.set(factory.type, factory); 10 | } 11 | 12 | getFactory(type: string): CommentFactory | undefined { 13 | return this.factories.get(type); 14 | } 15 | 16 | readonly factories = new Map(); 17 | } 18 | 19 | /** 20 | * A class that manages a map of `CommentWidgetFactory`s 21 | */ 22 | export class CommentWidgetRegistry implements ICommentWidgetRegistry { 23 | addFactory(factory: CommentWidgetFactory): void { 24 | this.factories.set(factory.widgetType, factory); 25 | } 26 | 27 | getFactory(type: string): CommentWidgetFactory | undefined { 28 | return this.factories.get(type); 29 | } 30 | 31 | readonly factories = new Map>(); 32 | } 33 | -------------------------------------------------------------------------------- /style/icons/user-icon-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-20.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/users/features.rst: -------------------------------------------------------------------------------- 1 | Features 2 | ========= 3 | 4 | User Icon and Color 5 | --------------------- 6 | 7 | Each user on a session has a unique ``identity`` which is designated by the profile picture and color associated with their comments. 8 | 9 | .. image:: ../images/init_comment.png 10 | :width: 500 11 | 12 | A user's name initalizes to ``Anonymous {Jupiter Moon Name}`` but can be altered by clicking on the pencil icon when hovering over your name. User color will be reflected in highlights when making text selection comments. 13 | 14 | .. image:: ../images/rename.png 15 | :width: 500 16 | 17 | Save and Refresh 18 | ------------------ 19 | 20 | Comment panels have a built-in auto save feature, however, this happens periodically. To save your comment, press the save icon in the top, and click on the refresh icon to see your changes reflected. 21 | 22 | .. image:: ../images/header.png 23 | :width: 500 24 | 25 | 26 | Markdown and LaTeX 27 | --------------------- 28 | 29 | Comments also support Markdown and LaTeX syntax. 30 | 31 | .. image:: ../images/md.png 32 | :width: 300 33 | 34 | .. image:: ../images/latex.png 35 | :width: 300 36 | 37 | -------------------------------------------------------------------------------- /style/icons/user-icon-23.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /style/icons/user-icon-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/notebook/utils.ts: -------------------------------------------------------------------------------- 1 | import { Cell } from '@jupyterlab/cells'; 2 | import { IThemeManager } from '@jupyterlab/apputils'; 3 | import * as CodeMirror from 'codemirror'; 4 | import { CodeMirrorEditor } from '@jupyterlab/codemirror'; 5 | import { ICellSelectionComment } from './commentformat'; 6 | import { toCodeMirrorPosition, truncate } from '../api'; 7 | 8 | export function docFromCell(cell: Cell): CodeMirror.Doc { 9 | return (cell.editorWidget.editor as CodeMirrorEditor).doc; 10 | } 11 | 12 | export function markCommentSelection( 13 | doc: CodeMirror.Doc, 14 | comment: ICellSelectionComment, 15 | theme: IThemeManager 16 | ): CodeMirror.TextMarker { 17 | const color = comment.identity.color; 18 | const r = parseInt(color.slice(1, 3), 16); 19 | const g = parseInt(color.slice(3, 5), 16); 20 | const b = parseInt(color.slice(5, 7), 16); 21 | 22 | const { start, end } = comment.target; 23 | const forward = 24 | start.line < end.line || 25 | (start.line === end.line && start.column <= end.column); 26 | const anchor = toCodeMirrorPosition(forward ? start : end); 27 | const head = toCodeMirrorPosition(forward ? end : start); 28 | return doc.markText(anchor, head, { 29 | className: 'jc-Highlight', 30 | title: `${comment.identity.name}: ${truncate(comment.text, 140)}`, 31 | css: `background-color: rgba( ${r}, ${g}, ${b}, ${ 32 | theme.theme === 'JupyterLab Light' ? 0.15 : 0.3 33 | })`, 34 | attributes: { id: `CommentMark-${comment.id}` } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/text/utils.ts: -------------------------------------------------------------------------------- 1 | import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; 2 | import { CodeMirrorEditor } from '@jupyterlab/codemirror'; 3 | import { ITextSelectionComment } from './commentformat'; 4 | import { IThemeManager } from '@jupyterlab/apputils'; 5 | import { toCodeMirrorPosition, truncate } from '../api'; 6 | import * as CodeMirror from 'codemirror'; 7 | 8 | export function docFromWrapper(wrapper: CodeEditorWrapper): CodeMirror.Doc { 9 | return (wrapper.editor as CodeMirrorEditor).doc; 10 | } 11 | 12 | export function markTextSelection( 13 | doc: CodeMirror.Doc, 14 | comment: ITextSelectionComment, 15 | theme: IThemeManager 16 | ): CodeMirror.TextMarker { 17 | const color = comment.identity.color; 18 | const r = parseInt(color.slice(1, 3), 16); 19 | const g = parseInt(color.slice(3, 5), 16); 20 | const b = parseInt(color.slice(5, 7), 16); 21 | 22 | const { start, end } = comment.target; 23 | const forward = 24 | start.line < end.line || 25 | (start.line === end.line && start.column <= end.column); 26 | const anchor = toCodeMirrorPosition(forward ? start : end); 27 | const head = toCodeMirrorPosition(forward ? end : start); 28 | 29 | return doc.markText(anchor, head, { 30 | className: 'jc-Highlight', 31 | title: `${comment.identity.name}: ${truncate(comment.text, 140)}`, 32 | css: `background-color: rgba( ${r}, ${g}, ${b}, ${ 33 | theme.theme === 'JupyterLab Light' ? 0.15 : 0.3 34 | })`, 35 | attributes: { id: `CommentMark-${comment.id}` } 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /docs/users/usage.md: -------------------------------------------------------------------------------- 1 | # User Experience 2 | 3 | ## Adding Comments 4 | 5 | Currently, JupyterLab Comments supports cell-level comments and text-selection comments. 6 | 7 | ### Cell-Level Comments 8 | 9 | To add a cell-level comment, right-click on the cell and select `Add Comment`. Fill out the mock comment and press submit. 10 | 11 | ### Text-Selection Comments 12 | 13 | Text selection comments are supported on both Jupyter Notebooks and plain text files (eg. .txt, .py, etc). 14 | 15 | #### Jupyter Notebooks 16 | 17 | To add a text selection comment to a Jupyter Notebook, either: 18 | 19 | 1. select the text in the desired cell and click on the blue comment indicator. Fill out the mock comment and press submit. 20 | 21 | 2. select the text in the desired cell, right-click, and select `Add Comment`. Fill out the mock comment - which should contain the selected text - and press submit. 22 | 23 | #### Plain Text Files 24 | 25 | Text selection comments in plain text files can be added by selecting text and either right-clicking and selecting `Add Comment`, or clicking on the blue comment indicator. 26 | 27 | ## Altering Comments 28 | 29 | ### Editing and Deleting 30 | 31 | To either edit or delete a comment, click on the three ellipses on the top right of a comment. 32 | 33 | ### Replying 34 | 35 | Replying to a comment can be done by either: 36 | 37 | 1. clicking on the three ellipses on the top right of a comment, and clicking `Reply Comment` 38 | 39 | 2. clicking on an un-focused comment (one which does not have a blue outline) and entering text through the reply box underneath the comment. 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Cameron Toy, Srirag Vuppala, and Rahul Nair 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 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ perform a development install of jupyterlab_comments 4 | On Binder, this will run _after_ the environment has been fully created from 5 | the environment.yml in this directory. 6 | This script should also run locally on Linux/MacOS/Windows: 7 | python3 binder/postBuild 8 | """ 9 | 10 | import subprocess 11 | import sys 12 | from pathlib import Path 13 | from shutil import copyfile 14 | 15 | ROOT = Path.cwd() 16 | 17 | def _(*args, **kwargs): 18 | """ Run a command, echoing the args 19 | fails hard if something goes wrong 20 | """ 21 | print("\n\t", " ".join(args), "\n") 22 | return_code = subprocess.call(args, **kwargs) 23 | if return_code != 0: 24 | print("\nERROR", return_code, " ".join(args)) 25 | sys.exit(return_code) 26 | 27 | # verify the environment is self-consistent before even starting 28 | _(sys.executable, "-m", "pip", "check") 29 | 30 | # install the labextension 31 | _(sys.executable, "-m", "pip", "install", "-e", ".") 32 | _(sys.executable, "-m", "jupyter", "labextension", "develop", "--overwrite", ".") 33 | 34 | # verify the environment the extension didn't break anything 35 | _(sys.executable, "-m", "pip", "check") 36 | 37 | # list the extensions 38 | _("jupyter", "server", "extension", "list") 39 | 40 | # initially list installed extensions to determine if there are any surprises 41 | _("jupyter", "labextension", "list") 42 | 43 | # copy jupyter_config to the root before starting JupyterLab 44 | copyfile("binder/jupyter_config.json", "jupyter_config.json") 45 | 46 | print("JupyterLab with jupyterlab_comments is ready to run with:\n") 47 | print("\tjupyter lab\n") 48 | 49 | -------------------------------------------------------------------------------- /src/text/widgetfactory.ts: -------------------------------------------------------------------------------- 1 | import { CommentFileModel, CommentWidgetFactory } from '../api'; 2 | import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; 3 | import { IThemeManager } from '@jupyterlab/apputils'; 4 | import { ITextSelectionComment } from './commentformat'; 5 | import { TextSelectionCommentWidget } from './widget'; 6 | import { docFromWrapper, markTextSelection } from './utils'; 7 | import { WidgetTracker } from '@jupyterlab/apputils'; 8 | 9 | export class TextSelectionCommentWidgetFactory extends CommentWidgetFactory< 10 | CodeEditorWrapper, 11 | ITextSelectionComment 12 | > { 13 | constructor( 14 | options: TextSelectionCommentWidgetFactory.IOptions, 15 | theme: IThemeManager 16 | ) { 17 | super(options); 18 | this._theme = theme; 19 | this._tracker = options.tracker; 20 | } 21 | 22 | createWidget( 23 | comment: ITextSelectionComment, 24 | model: CommentFileModel, 25 | target?: CodeEditorWrapper 26 | ): TextSelectionCommentWidget | undefined { 27 | const wrapper = target ?? this._tracker.currentWidget; 28 | if (wrapper == null) { 29 | console.error('No CodeEditorWrapper found for comment', comment); 30 | return; 31 | } 32 | 33 | const mark = markTextSelection( 34 | docFromWrapper(wrapper), 35 | comment, 36 | this._theme 37 | ); 38 | let theme = this._theme; 39 | return new TextSelectionCommentWidget({ 40 | comment, 41 | model, 42 | mark, 43 | target: wrapper, 44 | theme 45 | }); 46 | } 47 | 48 | readonly commentType = 'text-selection'; 49 | 50 | readonly widgetType = 'text-selection'; 51 | 52 | private _tracker: WidgetTracker; 53 | private _theme: IThemeManager; 54 | } 55 | 56 | export namespace TextSelectionCommentWidgetFactory { 57 | export interface IOptions extends CommentWidgetFactory.IOptions { 58 | tracker: WidgetTracker; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /style/panelHeader.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --jc-comment-border-color: #c5c5c5; 3 | --jc-comment-border: 1px solid var(--jc-comment-border-color); 4 | 5 | --jc-timestamp-color: var(--jp-ui-font-color2); 6 | } 7 | .jc-panelHeader { 8 | display: flex; 9 | flex-shrink: 0; 10 | flex-direction: row; 11 | justify-content: space-between; 12 | align-items: center; 13 | padding: 10px; 14 | z-index: 2; 15 | } 16 | .jc-panelHeader-identity-container { 17 | color: var(--jp-ui-font-color2); 18 | font-size: var(--jp-ui-font-size0); 19 | display: flex; 20 | justify-content: start; 21 | font-size: var(--jp-ui-font-size0); 22 | } 23 | .jc-panelHeader-editIcon { 24 | transform: scale(0.72); 25 | float: right; 26 | display: block; 27 | opacity: 0; 28 | } 29 | 30 | .jc-panelHeader-identity-container:hover .jc-panelHeader-editIcon { 31 | opacity: 1; 32 | } 33 | 34 | .jc-panelHeader-filename { 35 | font-size: var(--jp-ui-font-size1); 36 | color: var(--jp-ui-font-color0); 37 | margin-top: -3px; 38 | } 39 | 40 | .jc-panelHeader-left { 41 | color: black; 42 | } 43 | .jc-panelHeader-right { 44 | display: flex; 45 | flex-direction: row; 46 | justify-content: space-between; 47 | height: auto; 48 | align-items: start; 49 | margin-top: 5px; 50 | gap: 1em; 51 | } 52 | 53 | .jc-panelHeader-dropdown { 54 | color: var(--jc-timestamp-color); 55 | font-size: 14px; 56 | display: flex; 57 | align-items: center; 58 | margin-right: -5px; 59 | } 60 | 61 | .jc-panelHeader-EditInputArea-false { 62 | color: var(--jp-ui-font-color3); 63 | word-break: break-word; 64 | } 65 | .jc-panelHeader-EditInputArea-true { 66 | border: var(--jc-comment-border); 67 | color: var(--jp-ui-font-color3); 68 | word-break: break-word; 69 | } 70 | .jc-Button { 71 | cursor: pointer; 72 | } 73 | .jc-Button > svg { 74 | width: 20px; 75 | } 76 | 77 | .jc-DirtyIndicator { 78 | display: inline-block; 79 | width: 8px; 80 | height: 8px; 81 | border-radius: 50%; 82 | background-color: var(--jp-ui-font-color2); 83 | margin-left: 8px; 84 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.egg-info/ 5 | .ipynb_checkpoints 6 | *.tsbuildinfo 7 | jupyterlab_comments/labextension 8 | 9 | # Created by https://www.gitignore.io/api/python 10 | # Edit at https://www.gitignore.io/?templates=python 11 | 12 | ### Python ### 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Mr Developer 94 | .mr.developer.cfg 95 | .project 96 | .pydevproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .dmypy.json 104 | dmypy.json 105 | 106 | # Pyre type checker 107 | .pyre/ 108 | 109 | # End of https://www.gitignore.io/api/python 110 | 111 | # OSX files 112 | .DS_Store 113 | -------------------------------------------------------------------------------- /Press_Release.md: -------------------------------------------------------------------------------- 1 | Press Release 2 | 3 | ## JupyterLab Commenting 4 | 5 | **Teams can now comment on and annotate files within JupyterLab in real time.** 6 | 7 | Real-time collaboration (RTC) is now a core part of JupyterLab. Communication is vital for effective RTC, but until now, has been unavailable within JupyterLab. The commenting extension gives users a voice with live comments, quick replies, and search and filter. 8 | 9 | When working together on notebooks, collaborators often have recommendations and opinions about code and data. These recommendations have to be relayed outside JupyterLab, and it is hard to quickly convey exactly what needs to be changed and how--especially if the whole team isn’t using Git/GitHub. Without commenting and annotation, teams often find that JupyterLab doesn’t cut it for serious collaboration. 10 | 11 | The JupyterLab Commenting extension streamlines communication and enables users to comment on almost anything, including cells, outputs, text selections, datasets, and images. Comments are in markdown and support in-line LaTeX, as well as custom tags that allow you to easily label, sort, and search your team’s comments. Once created, comments live in a collapsible side panel where they can be searched and filtered for easy access. Quick replies allow users to swiftly address concerns while keeping in touch with their team by supplying common responses that can be sent with a single click. 12 | 13 | Commenting is easy, intuitive, and similar to other commenting systems users are familiar with. Simply highlight, right-click, and select “Add Comment” to comment on cells, text selections, and more. In addition, quick replies and non-intrusive notifications will help you seamlessly blend commenting into your existing workflow. 14 | 15 | _“Integrated commenting helped my team streamline our feedback process so we didn’t have to hold as many meetings. It also allowed me to collaborate with newer users that weren’t familiar with GitHub”_ ~ Satisfied User 16 | 17 | Don’t wait to collaborate! Open up new channels of communication with JupyterLab commenting. 18 | -------------------------------------------------------------------------------- /src/api/factory.ts: -------------------------------------------------------------------------------- 1 | import { IComment, IIdentity, IReply } from './commentformat'; 2 | import { UUID } from '@lumino/coreutils'; 3 | import { getCommentTimeStamp } from './utils'; 4 | import { CommentFileModel } from './model'; 5 | import { CommentWidget } from './widget'; 6 | import { ICommentRegistry } from './token'; 7 | 8 | export abstract class CommentWidgetFactory { 9 | constructor(options: CommentWidgetFactory.IOptions) { 10 | this.commentRegistry = options.commentRegistry; 11 | } 12 | 13 | abstract createWidget( 14 | comment: C, 15 | model: CommentFileModel, 16 | target?: T 17 | ): CommentWidget | undefined; 18 | 19 | get commentFactory(): CommentFactory | undefined { 20 | return this.commentRegistry.getFactory(this.commentType); 21 | } 22 | 23 | readonly widgetType: string = ''; 24 | readonly commentType: string = ''; 25 | readonly commentRegistry: ICommentRegistry; 26 | } 27 | 28 | export namespace CommentWidgetFactory { 29 | export interface IOptions { 30 | commentRegistry: ICommentRegistry; 31 | } 32 | } 33 | 34 | export abstract class CommentFactory { 35 | createComment(options: CommentFactory.ICommentOptions): C { 36 | const { identity, replies, id, text } = options; 37 | 38 | return { 39 | text, 40 | identity, 41 | type: this.type, 42 | time: getCommentTimeStamp(), 43 | editedTime: undefined, 44 | id: id ?? UUID.uuid4(), 45 | replies: replies ?? [], 46 | target: null 47 | } as C; 48 | } 49 | 50 | static createReply(options: CommentFactory.IReplyOptions): IReply { 51 | const { text, identity, id } = options; 52 | 53 | return { 54 | text, 55 | identity, 56 | id: id ?? UUID.uuid4(), 57 | time: getCommentTimeStamp(), 58 | editedTime: undefined, 59 | type: 'reply' 60 | }; 61 | } 62 | 63 | readonly type: string = ''; 64 | } 65 | 66 | export namespace CommentFactory { 67 | export interface IReplyOptions { 68 | text: string; 69 | identity: IIdentity; 70 | id?: string; 71 | } 72 | 73 | export interface ICommentOptions extends IReplyOptions { 74 | source: T; 75 | replies?: IReply[]; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'JupyterLab Comments' 21 | copyright = '2021, Srirag Vuppala, Cameron Toy, Rahul Nair, Chloe Frerichs' 22 | author = 'Srirag Vuppala, Cameron Toy, Rahul Nair, Chloe Frerichs' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = 'August 20th, 2021' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'myst_parser' 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = 'sphinx_rtd_theme' 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ['_static'] -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | jupyterlab_comments setup 3 | """ 4 | import json 5 | from pathlib import Path 6 | 7 | import setuptools 8 | 9 | HERE = Path(__file__).parent.resolve() 10 | 11 | # The name of the project 12 | name = "jupyterlab_comments" 13 | 14 | lab_path = (HERE / name.replace("-", "_") / "labextension") 15 | 16 | # Representative files that should exist after a successful build 17 | ensured_targets = [ 18 | str(lab_path / "package.json"), 19 | str(lab_path / "static/style.js") 20 | ] 21 | 22 | labext_name = "jupyterlab-comments" 23 | 24 | data_files_spec = [ 25 | ("share/jupyter/labextensions/%s" % labext_name, str(lab_path), "**"), 26 | ("share/jupyter/labextensions/%s" % labext_name, str(HERE), "install.json"), 27 | ] 28 | 29 | long_description = (HERE / "README.md").read_text() 30 | 31 | # Get the package info from package.json 32 | pkg_json = json.loads((HERE / "package.json").read_bytes()) 33 | 34 | setup_args = dict( 35 | name=name, 36 | version=pkg_json["version"], 37 | url=pkg_json["homepage"], 38 | author=pkg_json["author"]["name"], 39 | author_email=pkg_json["author"]["email"], 40 | description=pkg_json["description"], 41 | license=pkg_json["license"], 42 | long_description=long_description, 43 | long_description_content_type="text/markdown", 44 | packages=setuptools.find_packages(), 45 | install_requires=[ 46 | "jupyter_server>=1.6,<2" 47 | ], 48 | zip_safe=False, 49 | include_package_data=True, 50 | python_requires=">=3.6", 51 | platforms="Linux, Mac OS X, Windows", 52 | keywords=["Jupyter", "JupyterLab", "JupyterLab3"], 53 | classifiers=[ 54 | "License :: OSI Approved :: BSD License", 55 | "Programming Language :: Python", 56 | "Programming Language :: Python :: 3", 57 | "Programming Language :: Python :: 3.6", 58 | "Programming Language :: Python :: 3.7", 59 | "Programming Language :: Python :: 3.8", 60 | "Programming Language :: Python :: 3.9", 61 | "Framework :: Jupyter", 62 | ], 63 | ) 64 | 65 | try: 66 | from jupyter_packaging import ( 67 | wrap_installers, 68 | npm_builder, 69 | get_data_files 70 | ) 71 | post_develop = npm_builder( 72 | build_cmd="install:extension", source_dir="src", build_dir=lab_path 73 | ) 74 | setup_args['cmdclass'] = wrap_installers(post_develop=post_develop, ensured_targets=ensured_targets) 75 | setup_args['data_files'] = get_data_files(data_files_spec) 76 | except ImportError as e: 77 | pass 78 | 79 | if __name__ == "__main__": 80 | setuptools.setup(**setup_args) 81 | -------------------------------------------------------------------------------- /src/api/token.ts: -------------------------------------------------------------------------------- 1 | import { Token } from '@lumino/coreutils'; 2 | import { CommentFactory, CommentWidgetFactory } from './factory'; 3 | import { Menu, Panel } from '@lumino/widgets'; 4 | import { ISignal } from '@lumino/signaling'; 5 | import { CommentFileWidget, CommentWidget } from './widget'; 6 | import { Awareness } from 'y-protocols/awareness'; 7 | import { CommentFileModel } from './model'; 8 | import { NewCommentButton } from './button'; 9 | import { IIdentity } from './commentformat'; 10 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 11 | 12 | export interface ICommentRegistry { 13 | getFactory: (id: string) => CommentFactory | undefined; 14 | addFactory: (factory: CommentFactory) => void; 15 | 16 | readonly factories: Map; 17 | } 18 | 19 | export interface ICommentWidgetRegistry { 20 | getFactory: (id: string) => CommentWidgetFactory | undefined; 21 | addFactory: (factory: CommentWidgetFactory) => void; 22 | 23 | readonly factories: Map>; 24 | } 25 | 26 | export interface ICommentPanel extends Panel { 27 | /** 28 | * Scroll the comment with the given id into view. 29 | */ 30 | scrollToComment: (id: string) => void; 31 | 32 | /** 33 | * A signal emitted when a comment is added to the panel. 34 | */ 35 | commentAdded: ISignal>; 36 | 37 | /** 38 | * The dropdown menu for comment widgets. 39 | */ 40 | commentMenu: Menu; 41 | 42 | /** 43 | * A signal emitted when the panel is about to be shown. 44 | */ 45 | revealed: ISignal; 46 | 47 | /** 48 | * The current awareness associated with the panel. 49 | */ 50 | awareness: Awareness | undefined; 51 | 52 | /** 53 | * The current `CommentFileModel` associated with the panel. 54 | */ 55 | model: CommentFileModel | undefined; 56 | 57 | button: NewCommentButton; 58 | 59 | fileWidget: CommentFileWidget | undefined; 60 | 61 | localIdentity: IIdentity; 62 | 63 | commentRegistry: ICommentRegistry; 64 | 65 | commentWidgetRegistry: ICommentWidgetRegistry; 66 | 67 | loadModel( 68 | context: DocumentRegistry.IContext 69 | ): Promise; 70 | 71 | modelChanged: ISignal; 72 | 73 | mockComment( 74 | options: CommentFileWidget.IMockCommentOptions, 75 | index: number 76 | ): CommentWidget | undefined; 77 | } 78 | 79 | export const ICommentRegistry = new Token( 80 | 'jupyterlab-comments:comment-registry' 81 | ); 82 | 83 | export const ICommentWidgetRegistry = new Token( 84 | 'jupyterlab-comment:comment-widget-registry' 85 | ); 86 | 87 | export const ICommentPanel = new Token( 88 | 'jupyterlab-comments:comment-panel' 89 | ); 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jupyterlab_comments 2 | 3 | ![Github Actions Status](https://github.com/jupytercalpoly/jupyterlab-comments/workflows/Build/badge.svg)[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/jupytercalpoly/jupyterlab-comments/main?urlpath=lab)[![Documentation Status](https://readthedocs.org/projects/jupyterlab-comments/badge/?version=latest)](https://jupyterlab-comments.readthedocs.io/en/latest/?badge=latest) 4 | 5 | Comment on files and notebooks in JupyterLab 6 | 7 | ## Requirements 8 | 9 | - JupyterLab >= 3.1.6 10 | 11 | ## Contributing 12 | 13 | - Suggestions, bug reports, and code reviews are all welcome. Feel free to make an issue! 14 | 15 | ### Install 16 | 17 | You can install using `pip` 18 | 19 | `pip install jupyterlab-comments` 20 | 21 | ### Dev install 22 | 23 | Note: You will need NodeJS to build the extension package. 24 | 25 | The `jlpm` command is JupyterLab's pinned version of 26 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 27 | `yarn` or `npm` in lieu of `jlpm` below. 28 | 29 | ```bash 30 | # Clone the repo to your local environment 31 | # Change directory to the jupyterlab_comments directory 32 | # Install package in development mode 33 | pip install -e . 34 | # Link your development version of the extension with JupyterLab 35 | jupyter labextension develop . --overwrite 36 | # Rebuild extension Typescript source after making changes 37 | jlpm run build 38 | ``` 39 | 40 | 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. 41 | 42 | ```bash 43 | # Watch the source directory in one terminal, automatically rebuilding when needed 44 | jlpm run watch 45 | # Run JupyterLab in another terminal 46 | jupyter lab 47 | ``` 48 | 49 | 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). 50 | 51 | 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: 52 | 53 | ```bash 54 | jupyter lab build --minimize=False 55 | ``` 56 | 57 | ### Uninstall 58 | 59 | ```bash 60 | pip uninstall jupyterlab-comments 61 | ``` 62 | 63 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 64 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 65 | folder is located. Then you can remove the symlink named `jupyterlab-comments` within that folder. 66 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | commonjs: true, 6 | node: true, 7 | 'jest/globals': true 8 | }, 9 | globals: { 10 | JSX: 'readonly' 11 | }, 12 | root: true, 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:@typescript-eslint/eslint-recommended', 16 | 'plugin:@typescript-eslint/recommended', 17 | 'prettier/@typescript-eslint', 18 | 'plugin:react/recommended', 19 | 'plugin:jest/recommended' 20 | ], 21 | parser: '@typescript-eslint/parser', 22 | parserOptions: { 23 | project: 'tsconfig.json' 24 | }, 25 | plugins: ['@typescript-eslint', 'jest'], 26 | ignorePatterns: ['.eslintrc.js'], 27 | rules: { 28 | '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], 29 | '@typescript-eslint/naming-convention': [ 30 | 'error', 31 | { 32 | selector: 'interface', 33 | format: ['PascalCase'], 34 | custom: { 35 | regex: '^I[A-Z]', 36 | match: true 37 | } 38 | } 39 | ], 40 | '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], 41 | '@typescript-eslint/no-use-before-define': 'off', 42 | '@typescript-eslint/camelcase': 'off', 43 | '@typescript-eslint/no-explicit-any': 'off', 44 | '@typescript-eslint/explicit-module-boundary-types': 'off', 45 | '@typescript-eslint/no-non-null-assertion': 'off', 46 | '@typescript-eslint/no-namespace': 'off', 47 | '@typescript-eslint/interface-name-prefix': 'off', 48 | '@typescript-eslint/explicit-function-return-type': 'off', 49 | '@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }], 50 | '@typescript-eslint/ban-types': 'warn', 51 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'warn', 52 | '@typescript-eslint/no-var-requires': 'off', 53 | '@typescript-eslint/no-empty-interface': 'off', 54 | '@typescript-eslint/triple-slash-reference': 'warn', 55 | '@typescript-eslint/no-inferrable-types': 'off', 56 | 'jest/no-conditional-expect': 'warn', 57 | 'jest/valid-title': 'warn', 58 | 'no-inner-declarations': 'off', 59 | 'no-prototype-builtins': 'off', 60 | 'no-control-regex': 'warn', 61 | 'no-undef': 'warn', 62 | 'no-case-declarations': 'warn', 63 | 'no-useless-escape': 'off', 64 | 'prefer-const': 'off', 65 | 'react/prop-types': [0, { ignore: true }], 66 | 'sort-imports': [ 67 | 'error', 68 | { 69 | ignoreCase: true, 70 | ignoreDeclarationSort: true, 71 | ignoreMemberSort: false, 72 | memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], 73 | allowSeparatedGroups: false 74 | } 75 | ] 76 | }, 77 | settings: { 78 | react: { 79 | version: 'detect' 80 | } 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /.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 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Install node 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: '14.x' 19 | - name: Install Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.8' 23 | architecture: 'x64' 24 | 25 | 26 | - name: Setup pip cache 27 | uses: actions/cache@v2 28 | with: 29 | path: ~/.cache/pip 30 | key: pip-3.8-${{ hashFiles('package.json') }} 31 | restore-keys: | 32 | pip-3.8- 33 | pip- 34 | 35 | - name: Get yarn cache directory path 36 | id: yarn-cache-dir-path 37 | run: echo "::set-output name=dir::$(yarn cache dir)" 38 | - name: Setup yarn cache 39 | uses: actions/cache@v2 40 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 41 | with: 42 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 43 | key: yarn-${{ hashFiles('**/yarn.lock') }} 44 | restore-keys: | 45 | yarn- 46 | 47 | - name: Install dependencies 48 | run: python -m pip install -U jupyterlab~=3.0 check-manifest 49 | - name: Build the extension 50 | run: | 51 | set -eux 52 | jlpm 53 | jlpm run eslint:check 54 | python -m pip install . 55 | 56 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-comments.*OK" 57 | python -m jupyterlab.browser_check 58 | 59 | check-manifest -v 60 | 61 | pip install build 62 | python -m build --sdist 63 | cp dist/*.tar.gz myextension.tar.gz 64 | pip uninstall -y myextension jupyterlab 65 | rm -rf myextension 66 | 67 | - uses: actions/upload-artifact@v2 68 | with: 69 | name: myextension-sdist 70 | path: myextension.tar.gz 71 | 72 | test_isolated: 73 | needs: build 74 | runs-on: ubuntu-latest 75 | 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@v2 79 | - name: Install Python 80 | uses: actions/setup-python@v2 81 | with: 82 | python-version: '3.8' 83 | architecture: 'x64' 84 | - uses: actions/download-artifact@v2 85 | with: 86 | name: myextension-sdist 87 | - name: Install and Test 88 | run: | 89 | set -eux 90 | # Remove NodeJS, twice to take care of system and locally installed node versions. 91 | sudo rm -rf $(which node) 92 | sudo rm -rf $(which node) 93 | pip install myextension.tar.gz 94 | pip install jupyterlab 95 | jupyter labextension list 2>&1 | grep -ie "jupyterlab-comments.*OK" 96 | python -m jupyterlab.browser_check --no-chrome-test 97 | -------------------------------------------------------------------------------- /style/reply.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --jc-dropdown-size: 25px; 3 | 4 | --jc-comment-profile-pic-size: 32px; 5 | 6 | --jc-reply-pic-size: 24px; 7 | 8 | --jc-panel-background-color: var(--jp-layout-color0); 9 | 10 | --jc-comment-color: var(--jp-ui-font-color0); 11 | --jc-comment-background-color: var(--jp-layout-color1); 12 | --jc-comment-border-color: #c5c5c5; 13 | --jc-comment-border: 1px solid var(--jc-comment-border-color); 14 | 15 | --jc-timestamp-color: var(--jp-ui-font-color2); 16 | --jc-timestamp-font-size: smaller; 17 | --jc-timestamp-font-weight: regular; 18 | 19 | --jc-nametag-font-size: var(--jp-ui-font-size1); 20 | --jc-nametag-font-weight: 450; 21 | 22 | --var-jc-linesep: 2px; 23 | } 24 | 25 | .jc-ReplySpacer { 26 | margin-top: 8px; 27 | } 28 | .jc-ReplyInputArea:empty:before { 29 | content: attr(data-placeholder); 30 | color: var(--jp-ui-font-color3); 31 | } 32 | .jc-ReplyInputArea { 33 | border-top: var(--jc-comment-border); 34 | padding: 10px; 35 | word-break: break-word; 36 | } 37 | .jc-Reply { 38 | white-space: pre-wrap; 39 | border-top: var(--jc-comment-border); 40 | } 41 | 42 | .jc-Replies-breaker:focus { 43 | outline: 1px solid var(--jp-ui-font-color3); 44 | } 45 | 46 | .jc-Replies-breaker { 47 | height: 24px; 48 | z-index: 2; 49 | border-top: var(--jc-comment-border); 50 | position: relative; 51 | cursor: pointer; 52 | } 53 | 54 | 55 | .jc-Replies-breaker-right { 56 | overflow: hidden; 57 | } 58 | 59 | .jc-Replies-breaker-left { 60 | float: left; 61 | padding-left: 10px; 62 | padding-right: 10px; 63 | color: var(--jp-ui-font-color2); 64 | font-family: var(--jp-content-font-family); 65 | font-size: var(--jp-content-font-size1); 66 | line-height: var(--jp-content-line-height); 67 | } 68 | 69 | .jc-RepliesSpacer { 70 | float: right; 71 | width: 10px; 72 | height: 1px; 73 | } 74 | 75 | .jc-Replies-breaker-right > hr { 76 | border: 0.5px solid var(--jp-ui-font-color3); 77 | width: 100%; 78 | z-index: 1; 79 | } 80 | .jc-Replies-breaker-number { 81 | /* 32px circle */ 82 | border-radius: 50%; 83 | width: 32px; 84 | height: 32px; 85 | 86 | /* Color */ 87 | border: 1px solid var(--jp-ui-font-color3); 88 | background-color: var(--jc-panel-background-color); 89 | 90 | /* Center text */ 91 | display: flex; 92 | justify-content: center; 93 | align-items: center; 94 | 95 | z-index: 3; 96 | 97 | /* Pixel-pushing circle */ 98 | float: right; 99 | position: absolute; 100 | right: 20px; 101 | bottom: -5px; 102 | } 103 | 104 | .jc-CommentInput { 105 | width: 100%; 106 | min-height: 44px; 107 | padding: 5px 4px; 108 | } 109 | 110 | .jc-Indicator { 111 | cursor: pointer; 112 | position: absolute; 113 | text-align: center; 114 | z-index: 5; 115 | } 116 | 117 | .jc-ReplyPic { 118 | border-radius: 50%; 119 | top: 0; 120 | left: 0; 121 | height: var(--jc-reply-pic-size); 122 | height: var(--jc-reply-pic-size); 123 | display: flex; 124 | justify-content: center; 125 | align-items: center; 126 | } 127 | .jc-ReplyPicContainer { 128 | float: left; 129 | width: var(--jc-reply-pic-size); 130 | height: var(--jc-reply-pic-size); 131 | } -------------------------------------------------------------------------------- /src/notebook/widgetfactory.ts: -------------------------------------------------------------------------------- 1 | import { ICellComment, ICellSelectionComment } from './commentformat'; 2 | import { CommentFileModel, CommentWidgetFactory } from '../api'; 3 | import { Cell } from '@jupyterlab/cells'; 4 | import { IThemeManager } from '@jupyterlab/apputils'; 5 | import { INotebookTracker } from '@jupyterlab/notebook'; 6 | import { CellCommentWidget, CellSelectionCommentWidget } from './widget'; 7 | import { docFromCell, markCommentSelection } from './utils'; 8 | 9 | export class CellCommentWidgetFactory< 10 | C extends ICellComment = ICellComment 11 | > extends CommentWidgetFactory { 12 | constructor(options: CellCommentWidgetFactory.IOptions) { 13 | super(options); 14 | 15 | this._tracker = options.tracker; 16 | } 17 | 18 | createWidget( 19 | comment: ICellComment, 20 | model: CommentFileModel, 21 | target?: Cell 22 | ): CellCommentWidget | undefined { 23 | const cell = target ?? this._cellFromID(comment.target.cellID); 24 | if (cell == null) { 25 | console.error('Cell not found for comment', comment); 26 | return; 27 | } 28 | 29 | return new CellCommentWidget({ 30 | model, 31 | comment, 32 | target: cell 33 | }); 34 | } 35 | 36 | private _cellFromID(id: string): Cell | undefined { 37 | const notebook = this._tracker.currentWidget; 38 | if (notebook == null) { 39 | return; 40 | } 41 | 42 | return notebook.content.widgets.find(cell => cell.model.id === id); 43 | } 44 | 45 | readonly widgetType = 'cell'; 46 | readonly commentType = 'cell'; 47 | 48 | private _tracker: INotebookTracker; 49 | } 50 | 51 | export namespace CellCommentWidgetFactory { 52 | export interface IOptions extends CommentWidgetFactory.IOptions { 53 | tracker: INotebookTracker; 54 | } 55 | } 56 | 57 | export class CellSelectionCommentWidgetFactory extends CommentWidgetFactory< 58 | Cell, 59 | ICellSelectionComment 60 | > { 61 | constructor( 62 | options: CellCommentWidgetFactory.IOptions, 63 | theme: IThemeManager 64 | ) { 65 | super(options); 66 | 67 | this._tracker = options.tracker; 68 | this._theme = theme; 69 | } 70 | 71 | createWidget( 72 | comment: ICellSelectionComment, 73 | model: CommentFileModel, 74 | target?: Cell 75 | ): CellSelectionCommentWidget | undefined { 76 | const cell = target ?? this._cellFromID(comment.target.cellID); 77 | if (cell == null) { 78 | console.error('Cell not found for comment', comment); 79 | return; 80 | } 81 | 82 | const mark = markCommentSelection(docFromCell(cell), comment, this._theme); 83 | let theme = this._theme; 84 | return new CellSelectionCommentWidget({ 85 | model, 86 | comment, 87 | mark, 88 | target: cell, 89 | theme 90 | }); 91 | } 92 | 93 | private _cellFromID(id: string): Cell | undefined { 94 | const notebook = this._tracker.currentWidget; 95 | if (notebook == null) { 96 | return; 97 | } 98 | 99 | return notebook.content.widgets.find(cell => cell.model.id === id); 100 | } 101 | 102 | readonly widgetType = 'cell-selection'; 103 | readonly commentType = 'cell-selection'; 104 | 105 | private _tracker: INotebookTracker; 106 | private _theme: IThemeManager; 107 | } 108 | -------------------------------------------------------------------------------- /src/api/button.ts: -------------------------------------------------------------------------------- 1 | import { BlueCreateCommentIcon } from './icons'; 2 | import { Widget } from '@lumino/widgets'; 3 | import { Message } from '@lumino/messaging'; 4 | import { ISignal, Signal } from '@lumino/signaling'; 5 | 6 | export class NewCommentButton extends Widget { 7 | constructor() { 8 | super({ node: Private.createNode() }); 9 | } 10 | 11 | protected onAfterAttach(msg: Message): void { 12 | super.onAfterAttach(msg); 13 | this.node.addEventListener('click', this); 14 | } 15 | 16 | protected onAfterDetach(msg: Message): void { 17 | super.onAfterDetach(msg); 18 | this.node.removeEventListener('click', this); 19 | } 20 | 21 | handleEvent(event: Event): void { 22 | switch (event.type) { 23 | case 'click': 24 | this._handleClick(event as MouseEvent); 25 | break; 26 | } 27 | } 28 | 29 | private _handleClick(event: MouseEvent): void { 30 | event.preventDefault(); 31 | event.stopPropagation(); 32 | this._onClick(); 33 | this.close(); 34 | } 35 | 36 | close(): void { 37 | super.close(); 38 | this._closed.emit(undefined); 39 | } 40 | 41 | open(x: number, y: number, f: () => void, anchor?: HTMLElement): void { 42 | // Bail if button is already attached 43 | // if (this.isAttached) { 44 | // return; 45 | // } 46 | 47 | // Get position/size of main viewport 48 | const px = window.pageXOffset; 49 | const py = window.pageYOffset; 50 | const cw = document.documentElement.clientWidth; 51 | const ch = document.documentElement.clientHeight; 52 | let ax = 0; 53 | let ay = 0; 54 | 55 | if (anchor != null) { 56 | const { left, top } = anchor.getBoundingClientRect(); 57 | ax = anchor.scrollLeft - left; 58 | ay = anchor.scrollTop - top; 59 | } 60 | 61 | // Reset position 62 | const style = this.node.style; 63 | style.top = ''; 64 | style.left = ''; 65 | style.visibility = 'hidden'; 66 | 67 | if (!this.isAttached) { 68 | Widget.attach(this, anchor ?? document.body); 69 | } 70 | 71 | const { width, height } = this.node.getBoundingClientRect(); 72 | 73 | // Constrain button to the viewport 74 | if (x + width > px + cw) { 75 | x = px + cw - width; 76 | } 77 | if (y + height > py + ch) { 78 | if (y > py + ch) { 79 | y = py + ch - height; 80 | } else { 81 | y = y - height; 82 | } 83 | } 84 | 85 | // Adjust according to anchor 86 | x += ax; 87 | y += ay; 88 | 89 | // Add onclick function 90 | this._onClick = f; 91 | 92 | // Update button position and visibility 93 | style.top = `${Math.max(0, y)}px`; 94 | style.left = `${Math.max(0, x)}px`; 95 | style.visibility = ''; 96 | } 97 | 98 | get closed(): ISignal { 99 | return this._closed; 100 | } 101 | 102 | private _onClick: () => void = () => 103 | console.warn('no onClick function registered', this); 104 | 105 | private _closed = new Signal(this); 106 | } 107 | 108 | namespace Private { 109 | export function createNode() { 110 | const node = document.createElement('div'); 111 | node.className = 'jc-Indicator'; 112 | const icon = BlueCreateCommentIcon.element(); 113 | node.appendChild(icon); 114 | return node; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /demo/hh.py: -------------------------------------------------------------------------------- 1 | """ 2 | Author: Srirag Vuppala 3 | A model written to graph the Hodgkin - Huxley Equations to understand the gating kinetics for ionic channels (primarily potassium and sodium) within the cardiac cells. 4 | This analysis is done on a space clamped axon. 5 | """ 6 | 7 | import numpy as np 8 | 9 | 10 | class HodgkinHuxley(): 11 | C_m = 1 12 | """membrane capacitance, in uF/cm^2""" 13 | g_Na = 120 14 | """Sodium (Na) maximum conductances, in mS/cm^2""" 15 | g_K = 36 16 | """Postassium (K) maximum conductances, in mS/cm^2""" 17 | g_L = 0.3 18 | """Leak maximum conductances, in mS/cm^2""" 19 | V_Na = 115 20 | """Sodium (Na) Diffusion potentials, in mV""" 21 | V_K = -12 22 | """Postassium (K) Diffusion potentials, in mV""" 23 | V_L = -11 24 | """Leak current Diffusion potentials, in mV""" 25 | t = np.arange(0.0, 30.0, 0.01) 26 | """ The time to integrate over """ 27 | 28 | def alpha_m(self, V): 29 | """Channel gating kinetics. Functions of membrane voltage""" 30 | return 0.1*(25 - V)/(np.exp((25-V) / 10) - 1) 31 | def beta_m(self, V): 32 | """Channel gating kinetics. Functions of membrane voltage""" 33 | return 4.0*np.exp(-(V / 18.0)) 34 | 35 | def alpha_h(self, V): 36 | """Channel gating kinetics. Functions of membrane voltage""" 37 | return 0.07*np.exp(-(V / 20.0)) 38 | def beta_h(self, V): 39 | """Channel gating kinetics. Functions of membrane voltage""" 40 | return 1.0/(1.0 + np.exp(-(30 - V) / 10.0)) 41 | 42 | def alpha_n(self, V): 43 | """Channel gating kinetics. Functions of membrane voltage""" 44 | return 0.01*(10 - V)/(np.exp((10 - V) / 10.0) - 1) 45 | def beta_n(self, V): 46 | """Channel gating kinetics. Functions of membrane voltage""" 47 | return 0.125*np.exp(-(V / 80.0)) 48 | 49 | def n_inf(self, Vm=0.0): 50 | """ Inflection point potassium conductance to easily write gK""" 51 | return self.alpha_n(Vm) / (self.alpha_n(Vm) + self.beta_n(Vm)) 52 | def m_inf(self, Vm=0.0): 53 | """ Sodium activation variable """ 54 | return self.alpha_m(Vm) / (self.alpha_m(Vm) + self.beta_m(Vm)) 55 | def h_inf(self, Vm=0.0): 56 | """ Sodium inactivation variable """ 57 | return self.alpha_h(Vm) / (self.alpha_h(Vm) + self.beta_h(Vm)) 58 | 59 | # Input stimulus giver 60 | def Input_stimuli(self, t): 61 | """ Current applied to create stimulus which is dependent on time, in milli Ampere(A)/cm^2 """ 62 | if 0.0 < t < 5.0: 63 | return 150.0 64 | elif 10.0 < t < 30.0: 65 | return 50.0 66 | return 0.0 67 | 68 | def derivatives(self, y, t0): 69 | dy = [0]*4 70 | V = y[0] 71 | n = y[1] 72 | m = y[2] 73 | h = y[3] 74 | 75 | #encapsulating the remaining terms within the equation 76 | GK = (self.g_K / self.C_m) * np.power(n, 4.0) 77 | GNa = (self.g_Na / self.C_m) * np.power(m, 3.0) * h 78 | GL = self.g_L / self.C_m 79 | 80 | dy[0] = (self.Input_stimuli(t0) / self.C_m) - (GK * (V - self.V_K)) - (GNa * (V - self.V_Na)) - (GL * (V - self.V_L)) 81 | 82 | # dn/dt 83 | dy[1] = (self.alpha_n(V) * (1.0 - n)) - (self.beta_n(V) * n) 84 | 85 | # dm/dt 86 | dy[2] = (self.alpha_m(V) * (1.0 - m)) - (self.beta_m(V) * m) 87 | 88 | # dh/dt 89 | dy[3] = (self.alpha_h(V) * (1.0 - h)) - (self.beta_h(V) * h) 90 | 91 | return dy -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupyterlab-comments", 3 | "version": "0.1.2", 4 | "description": "Comment on files JupyterLab", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/jupytercalpoly/jupyterlab-comments", 11 | "bugs": { 12 | "url": "https://github.com/jupytercalpoly/jupyterlab-comments/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "Cameron Toy, Srirag Vuppala, and Rahul Nair", 17 | "email": "cameron.toy.00@gmail.com" 18 | }, 19 | "files": [ 20 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 21 | "style/**/*.{css,.js,eot,gif,html,jpg,json,png,svg,woff2,ttf}" 22 | ], 23 | "main": "lib/index.js", 24 | "types": "lib/index.d.ts", 25 | "style": "style/index.css", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/jupytercalpoly/jupyterlab-comments.git" 29 | }, 30 | "scripts": { 31 | "build": "jlpm run build:lib && jlpm run build:labextension:dev", 32 | "build:prod": "jlpm run clean && jlpm run build:lib && jlpm run build:labextension", 33 | "build:labextension": "jupyter labextension build .", 34 | "build:labextension:dev": "jupyter labextension build --development True .", 35 | "build:lib": "tsc", 36 | "clean": "jlpm run clean:lib", 37 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 38 | "clean:labextension": "rimraf jupyterlab_comments/labextension", 39 | "clean:all": "jlpm run clean:lib && jlpm run clean:labextension", 40 | "eslint": "eslint . --ext .ts,.tsx --fix", 41 | "eslint:check": "eslint . --ext .ts,.tsx", 42 | "prettier": "prettier --write \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 43 | "prettier:check": "prettier --list-different \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 44 | "install:extension": "jlpm run build", 45 | "watch": "run-p watch:src watch:labextension", 46 | "watch:src": "tsc -w", 47 | "watch:labextension": "jupyter labextension watch ." 48 | }, 49 | "dependencies": { 50 | "@jupyterlab/application": "^3.1.6", 51 | "@jupyterlab/cells": "^3.1.6", 52 | "@jupyterlab/codeeditor": "^3.1.6", 53 | "@jupyterlab/codemirror": "^3.1.6", 54 | "@jupyterlab/mathjax2": "^3.1.6", 55 | "@jupyterlab/notebook": "^3.1.6", 56 | "@jupyterlab/rendermime": "^3.1.6", 57 | "@jupyterlab/services": "^6.0.9", 58 | "@jupyterlab/shared-models": "^3.1.6", 59 | "@jupyterlab/ui-components": "^3.1.6", 60 | "@lumino/widgets": "^1.23.0", 61 | "@types/codemirror": "^5.60.2", 62 | "codemirror": "~5.61.0", 63 | "yjs": "^13.5.10" 64 | }, 65 | "devDependencies": { 66 | "@jupyterlab/builder": "^3.0.0", 67 | "@typescript-eslint/eslint-plugin": "^4.8.1", 68 | "@typescript-eslint/parser": "^4.8.1", 69 | "eslint": "^7.14.0", 70 | "eslint-config-prettier": "^6.15.0", 71 | "eslint-plugin-jest": "^24.3.6", 72 | "eslint-plugin-prettier": "^3.1.4", 73 | "eslint-plugin-react": "^7.24.0", 74 | "husky": "^6.0.0", 75 | "jest": "^27.0.4", 76 | "lint-staged": "^11.0.0", 77 | "npm-run-all": "^4.1.5", 78 | "prettier": "^2.1.1", 79 | "rimraf": "^3.0.2", 80 | "typescript": "~4.1.3" 81 | }, 82 | "sideEffects": [ 83 | "style/*.css", 84 | "style/index.js" 85 | ], 86 | "styleModule": "style/index.js", 87 | "jupyterlab": { 88 | "extension": true, 89 | "outputDir": "jupyterlab_comments/labextension", 90 | "webpackConfig": "webpack_config.js" 91 | }, 92 | "husky": { 93 | "hooks": { 94 | "pre-commit": "lint-staged" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/text/widget.ts: -------------------------------------------------------------------------------- 1 | import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; 2 | import { IThemeManager } from '@jupyterlab/apputils'; 3 | import { ITextSelectionComment } from './commentformat'; 4 | import { 5 | CommentWidget, 6 | toCodeEditorPosition, 7 | toCodeMirrorPosition, 8 | truncate 9 | } from '../api'; 10 | import * as CodeMirror from 'codemirror'; 11 | import { PartialJSONValue } from '@lumino/coreutils'; 12 | import { docFromWrapper, markTextSelection } from './utils'; 13 | 14 | export class TextSelectionCommentWidget extends CommentWidget< 15 | CodeEditorWrapper, 16 | ITextSelectionComment 17 | > { 18 | constructor(options: TextSelectionCommentWidget.IOptions) { 19 | super(options); 20 | this._theme = options.theme; 21 | this._mark = options.mark; 22 | 23 | this._theme.themeChanged.connect(() => { 24 | this._mark = markTextSelection( 25 | docFromWrapper(options.target), 26 | options.comment, 27 | this._theme 28 | ); 29 | }); 30 | } 31 | 32 | dispose(): void { 33 | this._mark.clear(); 34 | super.dispose(); 35 | } 36 | 37 | toJSON(): PartialJSONValue { 38 | const json = super.toJSON(); 39 | 40 | const mark = this._mark; 41 | if (mark == null) { 42 | console.warn( 43 | 'No mark found--serializing based on initial text selection position', 44 | this 45 | ); 46 | this.dispose(); 47 | this.model.deleteComment(this.commentID); 48 | return json; 49 | } 50 | 51 | const range = mark.find(); 52 | if (range == null) { 53 | console.warn( 54 | 'Mark no longer exists in code editor--serializing based on initial text selection position', 55 | this 56 | ); 57 | this.dispose(); 58 | this.model.deleteComment(this.commentID); 59 | return json; 60 | } 61 | 62 | const textSelectionComment = json as ITextSelectionComment; 63 | const { from, to } = range as CodeMirror.MarkerRange; 64 | textSelectionComment.target.start = toCodeEditorPosition(from); 65 | textSelectionComment.target.end = toCodeEditorPosition(to); 66 | 67 | return textSelectionComment; 68 | } 69 | 70 | getPreview(): string | undefined { 71 | if (this.isMock || this._mark == null) { 72 | return Private.getMockCommentPreviewText(this._doc, this.comment!); 73 | } 74 | 75 | const range = this._mark.find(); 76 | if (range == null) { 77 | return ''; 78 | } 79 | 80 | const { from, to } = range as CodeMirror.MarkerRange; 81 | const text = this._doc.getRange(from, to); 82 | 83 | return truncate(text, 140); 84 | } 85 | 86 | get element(): HTMLElement | undefined { 87 | return ( 88 | document.getElementById(`CommentMark-${this.commentID}`) ?? undefined 89 | ); 90 | } 91 | 92 | private get _doc(): CodeMirror.Doc { 93 | return docFromWrapper(this.target); 94 | } 95 | 96 | private _mark: CodeMirror.TextMarker; 97 | private _theme: IThemeManager; 98 | } 99 | 100 | export namespace TextSelectionCommentWidget { 101 | export interface IOptions 102 | extends CommentWidget.IOptions { 103 | mark: CodeMirror.TextMarker; 104 | theme: IThemeManager; 105 | } 106 | } 107 | 108 | namespace Private { 109 | export function getMockCommentPreviewText( 110 | doc: CodeMirror.Doc, 111 | comment: ITextSelectionComment 112 | ): string { 113 | const { start, end } = comment.target; 114 | const forward = 115 | start.line < end.line || 116 | (start.line === end.line && start.column <= end.column); 117 | const from = toCodeMirrorPosition(forward ? start : end); 118 | const to = toCodeMirrorPosition(forward ? end : start); 119 | const text = doc.getRange(from, to); 120 | 121 | return truncate(text, 140); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/notebook/widget.ts: -------------------------------------------------------------------------------- 1 | import { Cell } from '@jupyterlab/cells'; 2 | import { ICellComment, ICellSelectionComment } from './commentformat'; 3 | import * as CodeMirror from 'codemirror'; 4 | import { IThemeManager } from '@jupyterlab/apputils'; 5 | import { PartialJSONValue } from '@lumino/coreutils'; 6 | import { 7 | CommentWidget, 8 | toCodeEditorPosition, 9 | toCodeMirrorPosition, 10 | truncate 11 | } from '../api'; 12 | import { docFromCell, markCommentSelection } from './utils'; 13 | 14 | export class CellCommentWidget extends CommentWidget { 15 | constructor(options: CommentWidget.IOptions) { 16 | super(options); 17 | } 18 | 19 | get element(): HTMLElement { 20 | return this.target.node; 21 | } 22 | } 23 | 24 | export class CellSelectionCommentWidget extends CommentWidget< 25 | Cell, 26 | ICellSelectionComment 27 | > { 28 | constructor(options: CellSelectionCommentWidget.IOptions) { 29 | super(options); 30 | this._mark = options.mark; 31 | this._theme = options.theme; 32 | 33 | this._theme.themeChanged.connect(() => { 34 | this._mark = markCommentSelection( 35 | docFromCell(options.target), 36 | options.comment, 37 | this._theme 38 | ); 39 | }); 40 | } 41 | 42 | dispose(): void { 43 | this._mark.clear(); 44 | super.dispose(); 45 | } 46 | 47 | get element(): HTMLElement { 48 | return this.target.node; 49 | } 50 | 51 | toJSON(): PartialJSONValue { 52 | const json = super.toJSON(); 53 | 54 | const mark = this._mark; 55 | if (mark == null) { 56 | console.warn( 57 | 'No mark found--serializing based on initial text selection position', 58 | this 59 | ); 60 | this.dispose(); 61 | this.model.deleteComment(this.commentID); 62 | return json; 63 | } 64 | 65 | const range = mark.find(); 66 | if (range == null) { 67 | console.warn( 68 | 'Mark no longer exists in code editor--serializing based on initial text selection position', 69 | this 70 | ); 71 | this.dispose(); 72 | this.model.deleteComment(this.commentID); 73 | return json; 74 | } 75 | 76 | const { from, to } = range as CodeMirror.MarkerRange; 77 | const textSelectionComment = json as ICellSelectionComment; 78 | 79 | textSelectionComment.target.cellID = this.target.model.id; 80 | textSelectionComment.target.start = toCodeEditorPosition(from); 81 | textSelectionComment.target.end = toCodeEditorPosition(to); 82 | 83 | return textSelectionComment; 84 | } 85 | 86 | getPreview(): string | undefined { 87 | if (this.isMock || this._mark == null) { 88 | return Private.getMockCommentPreviewText(this._doc, this.comment!); 89 | } 90 | 91 | const range = this._mark.find(); 92 | if (range == null) { 93 | return ''; 94 | } 95 | 96 | const { from, to } = range as CodeMirror.MarkerRange; 97 | const text = this._doc.getRange(from, to); 98 | 99 | return truncate(text, 140); 100 | } 101 | 102 | private get _doc(): CodeMirror.Doc { 103 | return docFromCell(this.target); 104 | } 105 | 106 | private _mark: CodeMirror.TextMarker; 107 | private _theme: IThemeManager; 108 | } 109 | 110 | export namespace CellSelectionCommentWidget { 111 | export interface IOptions 112 | extends CommentWidget.IOptions { 113 | mark: CodeMirror.TextMarker; 114 | theme: IThemeManager; 115 | } 116 | } 117 | 118 | namespace Private { 119 | export function getMockCommentPreviewText( 120 | doc: CodeMirror.Doc, 121 | comment: ICellSelectionComment 122 | ): string { 123 | const { start, end } = comment.target; 124 | const forward = 125 | start.line < end.line || 126 | (start.line === end.line && start.column <= end.column); 127 | const from = toCodeMirrorPosition(forward ? start : end); 128 | const to = toCodeMirrorPosition(forward ? end : start); 129 | const text = doc.getRange(from, to); 130 | 131 | return truncate(text, 140); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { Awareness } from 'y-protocols/awareness'; 2 | import { IIdentity } from './commentformat'; 3 | import { getAnonymousUserName } from '@jupyterlab/docprovider'; 4 | import { UserIcons } from './icons'; 5 | import { CodeEditor } from '@jupyterlab/codeeditor'; 6 | 7 | export const emptyIdentity: IIdentity = { 8 | id: 0, 9 | icon: 0, 10 | name: 'User', 11 | color: '' 12 | }; 13 | 14 | let count = -1; 15 | export function randomIdentity(): IIdentity { 16 | return { 17 | id: count--, 18 | name: getAnonymousUserName(), 19 | color: randomColor(), 20 | icon: Math.floor(Math.random() * UserIcons.length) 21 | }; 22 | } 23 | 24 | export function randomColor(): string { 25 | const validColors = [ 26 | '#eb5351', 27 | '#ea357a', 28 | '#f57c00', 29 | '#dca927', 30 | '#24be61', 31 | '#8ed97c', 32 | '#ff709b', 33 | '#d170ff', 34 | '#7b61ff', 35 | '#4176ff', 36 | '#70c3ff', 37 | '#a8b84a' 38 | ]; 39 | return validColors[Math.floor(Math.random() * validColors.length)]; 40 | } 41 | 42 | export function setIdentityName(awareness: Awareness, name: string): boolean { 43 | let localState = awareness.getLocalState(); 44 | if (localState == null) { 45 | return false; 46 | } 47 | const oldUser = localState['user']; 48 | if (oldUser == null) { 49 | return false; 50 | } 51 | let newUser = { 52 | name: name, 53 | color: oldUser['color'], 54 | icon: oldUser['icon'] ?? Math.floor(Math.random() * UserIcons.length) 55 | }; 56 | awareness.setLocalStateField('user', newUser); 57 | 58 | //Checking if the localState has been updated 59 | localState = awareness.getLocalState(); 60 | if (localState == null) { 61 | return false; 62 | } 63 | if (localState['user']['name'] != name) { 64 | return false; 65 | } 66 | return true; 67 | } 68 | 69 | export function getIdentity(awareness: Awareness): IIdentity { 70 | const localState = awareness.getLocalState(); 71 | if (localState == null) { 72 | return emptyIdentity; 73 | } 74 | 75 | const userInfo = localState['user']; 76 | if ( 77 | userInfo != null && 78 | 'name' in userInfo && 79 | 'color' in userInfo && 80 | 'icon' in userInfo 81 | ) { 82 | return { 83 | id: awareness.clientID, 84 | name: userInfo['name'], 85 | color: userInfo['color'], 86 | icon: userInfo['icon'] 87 | }; 88 | } 89 | 90 | return randomIdentity(); 91 | } 92 | 93 | export function getCommentTimeStamp(): string { 94 | return new Date().toString(); 95 | } 96 | 97 | 98 | export function renderCommentTimeString(timeString: string): string { 99 | const d = new Date(timeString) 100 | const time = d.toLocaleString('default', { 101 | hour: 'numeric', 102 | minute: 'numeric', 103 | hour12: true 104 | }); 105 | const date = d.toLocaleString('default', { 106 | month: 'short', 107 | day: 'numeric' 108 | }); 109 | return time + ' ' + date 110 | } 111 | 112 | //function that converts a line-column pairing to an index 113 | export function lineToIndex(str: string, line: number, col: number): number { 114 | if (line == 0) { 115 | return col; 116 | } else { 117 | let arr = str.split('\n'); 118 | return arr.slice(0, line).join('\n').length + col + 1; 119 | } 120 | } 121 | 122 | export function hashString(s: string): number { 123 | let hash = 0; 124 | if (s.length == 0) { 125 | return hash; 126 | } 127 | for (let i = 0; i < s.length; i++) { 128 | let char = s.charCodeAt(i); 129 | hash = (hash << 5) - hash + char; 130 | hash = hash & hash; // Convert to 32bit integer 131 | } 132 | return hash; 133 | } 134 | 135 | export function truncate(text: string, maxLength: number): string { 136 | return text.length > maxLength ? text.slice(0, maxLength) + '...' : text; 137 | } 138 | 139 | export function toCodeMirrorPosition( 140 | pos: CodeEditor.IPosition 141 | ): CodeMirror.Position { 142 | return { 143 | line: pos.line, 144 | ch: pos.column 145 | }; 146 | } 147 | 148 | export function toCodeEditorPosition( 149 | pos: CodeMirror.Position 150 | ): CodeEditor.IPosition { 151 | return { 152 | line: pos.line, 153 | column: pos.ch 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /style/comment.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --jc-comment-profile-pic-size: 32px; 3 | 4 | --jc-comment-color: var(--jp-ui-font-color0); 5 | --jc-comment-background-color: var(--jp-layout-color1); 6 | --jc-comment-border-color: #c5c5c5; 7 | --jc-comment-border: 1px solid var(--jc-comment-border-color); 8 | 9 | --jc-timestamp-color: var(--jp-ui-font-color2); 10 | --jc-timestamp-font-size: smaller; 11 | --jc-timestamp-font-weight: regular; 12 | 13 | --jc-nametag-font-size: var(--jp-ui-font-size1); 14 | --jc-nametag-font-weight: 450; 15 | 16 | --var-jc-linesep: 2px; 17 | } 18 | .jc-CommentFileWidget { 19 | overflow: auto; 20 | flex-grow: 1; 21 | padding-top: 6px; 22 | } 23 | 24 | .jc-Comment { 25 | background-color: var(--jc-comment-background-color); 26 | padding: 10px 10px 7px 10px; 27 | } 28 | 29 | .jc-Nametag { 30 | font-size: var(--jc-nametag-font-size); 31 | font-weight: var(--jc-nametag-font-weight); 32 | margin-left: 10px; 33 | } 34 | 35 | .jc-Time { 36 | font-size: var(--jc-timestamp-font-size); 37 | font-weight: var(--jc-timestamp-font-weight); 38 | color: var(--jc-timestamp-color); 39 | vertical-align: top; 40 | margin-left: 10px; 41 | } 42 | 43 | /* comment */ 44 | .jc-MarkdownBody { 45 | margin: 0; 46 | padding: 0; 47 | } 48 | 49 | /* replies */ 50 | .jc-MarkdownBody > * { 51 | display: inline-block; 52 | margin: 0; 53 | padding: 0; 54 | } 55 | 56 | .jc-Body { 57 | font-size: 0.833rem; 58 | outline: none; 59 | border: none; 60 | background-color: transparent; 61 | white-space: pre-wrap; 62 | margin-top: var(--var-jc-linesep); 63 | line-height: 1.2em; 64 | padding: 3px 1px; 65 | min-height: 1.2em; 66 | } 67 | 68 | .jc-Body strong { 69 | font-weight: bold; 70 | } 71 | 72 | .jc-Body a { 73 | color: revert; 74 | } 75 | 76 | .jc-Body * { 77 | margin: 0; 78 | } 79 | 80 | .jc-Body:focus { 81 | outline: 1px solid var(--jp-ui-font-color3); 82 | } 83 | 84 | .jc-SubmitButtons { 85 | display: flex; 86 | gap: 5px; 87 | align-items: center; 88 | justify-content: center; 89 | padding: 3px; 90 | } 91 | 92 | .jc-SubmitButtons > * { 93 | padding: 2px; 94 | border: 1px solid #64b5f6; 95 | border-radius: 8px; 96 | font-size: small; 97 | width: 66px; 98 | text-align: center; 99 | cursor: pointer; 100 | } 101 | 102 | .jc-SubmitButton { 103 | background-color: #64b5f6; 104 | } 105 | 106 | /* .jc-SubmitButtonInactive { 107 | background-color: #adafb0; 108 | } */ 109 | 110 | .jc-CommentProfilePic { 111 | display: block; 112 | border-radius: 50%; 113 | top: 0; 114 | left: 0; 115 | height: var(--jc-comment-profile-pic-size); 116 | width: var(--jc-comment-profile-pic-size); 117 | display: flex; 118 | justify-content: center; 119 | align-items: center; 120 | } 121 | .jc-CommentProfilePicContainer { 122 | float: left; 123 | width: var(--jc-comment-profile-pic-size); 124 | } 125 | 126 | .jc-Ellipses { 127 | float: right; 128 | width: var(--jc-dropdown-size); 129 | height: var(--jc-dropdown-size); 130 | display: none; 131 | } 132 | .jc-Comment:hover .jc-Ellipses { 133 | display: inline-block; 134 | } 135 | .jc-Ellipses > svg { 136 | width: var(--jc-dropdown-size); 137 | } 138 | 139 | .jc-Error { 140 | height: 120px; 141 | width: 120px; 142 | background-color: red; 143 | } 144 | 145 | .jc-MoonIcon { 146 | width: 80%; 147 | height: 80%; 148 | } 149 | 150 | .jc-Preview { 151 | display: grid; 152 | grid-template-columns: 6px calc(100% - 6px); 153 | padding-left: 10px; 154 | margin-top: var(--var-jc-linesep); 155 | } 156 | .jc-PreviewBar { 157 | width: 2px; 158 | height: 100%; 159 | background-color: var(--jp-warn-color0); 160 | } 161 | .jc-PreviewText { 162 | color: var(--jc-timestamp-color); 163 | word-wrap: break-word; 164 | white-space: pre-wrap; 165 | word-break: break-word; 166 | margin-left: 10px; 167 | } 168 | 169 | .jc-CommentWidget:focus-within .jc-mod-focus-border { 170 | border-color: var(--jp-brand-color1); 171 | } 172 | .jc-CommentWidget:focus-within { 173 | border-color: var(--jp-brand-color1); 174 | } 175 | 176 | .jc-CommentPanel { 177 | background-color: var(--jc-panel-background-color); 178 | color: var(--jc-comment-color); 179 | display: flex; 180 | flex-direction: column; 181 | } 182 | 183 | 184 | .jc-CommentWidget { 185 | margin: 0 8px 6px 8px; 186 | border: var(--jc-comment-border); 187 | border-radius: 5px; 188 | } -------------------------------------------------------------------------------- /src/notebook/plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { IThemeManager } from '@jupyterlab/apputils'; 6 | import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook'; 7 | import { YNotebook } from '@jupyterlab/shared-models'; 8 | import { Awareness } from 'y-protocols/awareness'; 9 | import { Cell } from '@jupyterlab/cells'; 10 | import { getIdentity, ICommentPanel } from '../api'; 11 | import { 12 | CellCommentFactory, 13 | CellSelectionCommentFactory 14 | } from './commentfactory'; 15 | import { 16 | CellCommentWidgetFactory, 17 | CellSelectionCommentWidgetFactory 18 | } from './widgetfactory'; 19 | import { ICellComment } from './commentformat'; 20 | import { CodeEditor } from '@jupyterlab/codeeditor'; 21 | 22 | export namespace CommandIDs { 23 | export const addNotebookComment = 'jl-comments:add-notebook-comment'; 24 | } 25 | 26 | /** 27 | * A plugin that allows notebooks to be commented on. 28 | */ 29 | export const notebookCommentsPlugin: JupyterFrontEndPlugin = { 30 | id: 'jupyterlab-comments:notebook', 31 | autoStart: true, 32 | requires: [INotebookTracker, ICommentPanel, IThemeManager], 33 | activate: ( 34 | app: JupyterFrontEnd, 35 | nbTracker: INotebookTracker, 36 | panel: ICommentPanel, 37 | manager: IThemeManager 38 | ) => { 39 | const commentRegistry = panel.commentRegistry; 40 | const commentWidgetRegistry = panel.commentWidgetRegistry; 41 | 42 | commentRegistry.addFactory(new CellCommentFactory()); 43 | commentRegistry.addFactory(new CellSelectionCommentFactory()); 44 | 45 | commentWidgetRegistry.addFactory( 46 | new CellCommentWidgetFactory({ commentRegistry, tracker: nbTracker }) 47 | ); 48 | commentWidgetRegistry.addFactory( 49 | new CellSelectionCommentWidgetFactory( 50 | { 51 | commentRegistry, 52 | tracker: nbTracker 53 | }, 54 | manager 55 | ) 56 | ); 57 | 58 | app.commands.addCommand(CommandIDs.addNotebookComment, { 59 | label: 'Add Comment', 60 | execute: () => { 61 | const cell = nbTracker.activeCell; 62 | if (cell == null) { 63 | return; 64 | } 65 | 66 | const model = panel.model; 67 | if (model == null) { 68 | return; 69 | } 70 | 71 | const comments = model.comments; 72 | let index = comments.length; 73 | for (let i = comments.length; i > 0; i--) { 74 | const comment = comments.get(i - 1) as ICellComment; 75 | if (comment.target.cellID === cell.model.id) { 76 | index = i; 77 | } 78 | } 79 | 80 | const { start, end } = cell.editor.getSelection(); 81 | const type = 82 | start.column === end.column && start.line === end.line 83 | ? 'cell' 84 | : 'cell-selection'; 85 | 86 | panel.mockComment( 87 | { 88 | identity: getIdentity(model.awareness), 89 | type, 90 | source: cell 91 | }, 92 | index 93 | ); 94 | } 95 | }); 96 | 97 | // This updates the indicator and scrolls to the comments of the selected cell 98 | // when the active cell changes. 99 | let currentCell: Cell | null = null; 100 | nbTracker.activeCellChanged.connect((_, cell: Cell | null) => { 101 | // Clean up old mouseup listener 102 | document.removeEventListener('mouseup', onMouseup); 103 | 104 | currentCell = cell; 105 | panel.button.close(); 106 | 107 | // panel.model can be null when the notebook is first loaded 108 | if (cell == null || panel.model == null) { 109 | return; 110 | } 111 | 112 | // Scroll to the first comment associated with the currently selected cell. 113 | for (let comment of panel.model.comments) { 114 | if (comment.type === 'cell-selection' || comment.type === 'cell') { 115 | const cellComment = comment as ICellComment; 116 | if (cellComment.target.cellID === cell.model.id) { 117 | panel.scrollToComment(cellComment.id); 118 | break; 119 | } 120 | } 121 | } 122 | }); 123 | 124 | let currentSelection: CodeEditor.IRange; 125 | 126 | // Opens add comment button on the current cell when the mouse is released 127 | // after a text selection 128 | const onMouseup = (_: MouseEvent): void => { 129 | if (currentCell == null || currentCell.isDisposed) { 130 | return; 131 | } 132 | 133 | const editor = currentCell.editor; 134 | const { top } = editor.getCoordinateForPosition(currentSelection.start); 135 | const { bottom } = editor.getCoordinateForPosition(currentSelection.end); 136 | const { right } = currentCell.editorWidget.node.getBoundingClientRect(); 137 | 138 | const node = nbTracker.currentWidget!.content.node; 139 | 140 | panel.button.open( 141 | right - 10, 142 | (top + bottom) / 2 - 10, 143 | () => app.commands.execute(CommandIDs.addNotebookComment), 144 | node 145 | ); 146 | }; 147 | 148 | // Adds a single-run mouseup listener whenever a text selection is made in a cell 149 | const awarenessHandler = (): void => { 150 | if (currentCell == null) { 151 | return; 152 | } 153 | 154 | currentSelection = currentCell.editor.getSelection(); 155 | const { start, end } = currentSelection; 156 | 157 | if (start.column !== end.column || start.line !== end.line) { 158 | document.addEventListener('mouseup', onMouseup, { once: true }); 159 | } else { 160 | panel.button.close(); 161 | } 162 | }; 163 | 164 | let lastAwareness: Awareness | null = null; 165 | nbTracker.currentChanged.connect((_, notebook: NotebookPanel | null) => { 166 | if (notebook == null) { 167 | lastAwareness = null; 168 | return; 169 | } 170 | 171 | // Clean up old awareness handler 172 | if (lastAwareness != null) { 173 | lastAwareness.off('change', awarenessHandler); 174 | } 175 | 176 | // Add new awareness handler 177 | const model = notebook.model!.sharedModel as YNotebook; 178 | model.awareness.on('change', awarenessHandler); 179 | 180 | lastAwareness = model.awareness; 181 | }); 182 | 183 | app.contextMenu.addItem({ 184 | command: CommandIDs.addNotebookComment, 185 | selector: '.jp-Notebook .jp-Cell', 186 | rank: 13 187 | }); 188 | } 189 | }; 190 | 191 | export default notebookCommentsPlugin; 192 | -------------------------------------------------------------------------------- /src/api/icons.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | //template 4 | // export const fooIcon = new LabIcon({ 5 | // name: 'barpkg:foo', 6 | // svgstr: '...' 7 | // }); 8 | 9 | import userIcon0Svgstr from '../../style/icons/user-icon-0.svg'; 10 | import userIcon1Svgstr from '../../style/icons/user-icon-1.svg'; 11 | import userIcon2Svgstr from '../../style/icons/user-icon-2.svg'; 12 | import userIcon3Svgstr from '../../style/icons/user-icon-3.svg'; 13 | import userIcon4Svgstr from '../../style/icons/user-icon-4.svg'; 14 | import userIcon5Svgstr from '../../style/icons/user-icon-5.svg'; 15 | import userIcon6Svgstr from '../../style/icons/user-icon-6.svg'; 16 | import userIcon7Svgstr from '../../style/icons/user-icon-7.svg'; 17 | import userIcon8Svgstr from '../../style/icons/user-icon-8.svg'; 18 | import userIcon9Svgstr from '../../style/icons/user-icon-9.svg'; 19 | import userIcon10Svgstr from '../../style/icons/user-icon-10.svg'; 20 | import userIcon11Svgstr from '../../style/icons/user-icon-11.svg'; 21 | import userIcon12Svgstr from '../../style/icons/user-icon-12.svg'; 22 | import userIcon13Svgstr from '../../style/icons/user-icon-13.svg'; 23 | import userIcon14Svgstr from '../../style/icons/user-icon-14.svg'; 24 | import userIcon15Svgstr from '../../style/icons/user-icon-15.svg'; 25 | import userIcon16Svgstr from '../../style/icons/user-icon-16.svg'; 26 | import userIcon17Svgstr from '../../style/icons/user-icon-17.svg'; 27 | import userIcon18Svgstr from '../../style/icons/user-icon-18.svg'; 28 | import userIcon19Svgstr from '../../style/icons/user-icon-19.svg'; 29 | import userIcon20Svgstr from '../../style/icons/user-icon-20.svg'; 30 | import userIcon21Svgstr from '../../style/icons/user-icon-21.svg'; 31 | import userIcon22Svgstr from '../../style/icons/user-icon-22.svg'; 32 | import userIcon23Svgstr from '../../style/icons/user-icon-23.svg'; 33 | 34 | const userIconSvgstrs = [ 35 | userIcon0Svgstr, 36 | userIcon1Svgstr, 37 | userIcon2Svgstr, 38 | userIcon3Svgstr, 39 | userIcon4Svgstr, 40 | userIcon5Svgstr, 41 | userIcon6Svgstr, 42 | userIcon7Svgstr, 43 | userIcon8Svgstr, 44 | userIcon9Svgstr, 45 | userIcon10Svgstr, 46 | userIcon11Svgstr, 47 | userIcon12Svgstr, 48 | userIcon13Svgstr, 49 | userIcon14Svgstr, 50 | userIcon15Svgstr, 51 | userIcon16Svgstr, 52 | userIcon17Svgstr, 53 | userIcon18Svgstr, 54 | userIcon19Svgstr, 55 | userIcon20Svgstr, 56 | userIcon21Svgstr, 57 | userIcon22Svgstr, 58 | userIcon23Svgstr 59 | ]; 60 | 61 | export function randomIcon(): LabIcon { 62 | return UserIcons[Math.floor(Math.random() * UserIcons.length)]; 63 | } 64 | 65 | export const UserIcons = userIconSvgstrs.map((svgstr, index) => { 66 | return new LabIcon({ 67 | name: `UserIcon${index}`, 68 | svgstr 69 | }); 70 | }); 71 | 72 | export const CommentsHubIcon = new LabIcon({ 73 | name: 'CommentsHubIcon', 74 | svgstr: ` 75 | 76 | 77 | 78 | 79 | 80 | ` 81 | }); 82 | 83 | export const CommentsPanelIcon = new LabIcon({ 84 | name: 'CommentsPanelIcon', 85 | svgstr: ` 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | ` 98 | }); 99 | 100 | export const CreateCommentIcon = new LabIcon({ 101 | name: 'CreateCommentIcon', 102 | svgstr: ` 103 | 104 | 105 | ` 106 | }); 107 | 108 | export const OrangeCreateCommentIcon = new LabIcon({ 109 | name: 'OrangeCreateCommentIcon', 110 | svgstr: ` 111 | 112 | 113 | ` 114 | }); 115 | 116 | export const BlueCreateCommentIcon = new LabIcon({ 117 | name: 'BlueCreateCommentIcon', 118 | svgstr: ` 119 | 120 | 121 | ` 122 | }); 123 | -------------------------------------------------------------------------------- /src/text/plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILabShell, 3 | JupyterFrontEnd, 4 | JupyterFrontEndPlugin 5 | } from '@jupyterlab/application'; 6 | import { getIdentity, ICommentPanel } from '../api'; 7 | import { WidgetTracker } from '@jupyterlab/apputils'; 8 | import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; 9 | import { TextSelectionCommentFactory } from './commentfactory'; 10 | import { TextSelectionCommentWidgetFactory } from './widgetfactory'; 11 | import { DocumentWidget } from '@jupyterlab/docregistry'; 12 | import { ITextSelectionComment } from './commentformat'; 13 | import { Awareness } from 'y-protocols/awareness'; 14 | import { YFile } from '@jupyterlab/shared-models'; 15 | import { Widget } from '@lumino/widgets'; 16 | import { IThemeManager } from '@jupyterlab/apputils'; 17 | 18 | namespace CommandIDs { 19 | export const addComment = 'jupyter-comments:add-text-comment'; 20 | } 21 | 22 | export const textCommentingPlugin: JupyterFrontEndPlugin = { 23 | id: 'jupyterlab-comments:text', 24 | autoStart: true, 25 | requires: [ICommentPanel, ILabShell, IThemeManager], 26 | activate: ( 27 | app: JupyterFrontEnd, 28 | panel: ICommentPanel, 29 | shell: ILabShell, 30 | manager: IThemeManager 31 | ) => { 32 | const commentRegistry = panel.commentRegistry; 33 | const commentWidgetRegistry = panel.commentWidgetRegistry; 34 | 35 | const editorTracker = new WidgetTracker({ 36 | namespace: 'code-editor-wrappers' 37 | }); 38 | 39 | commentRegistry.addFactory(new TextSelectionCommentFactory()); 40 | 41 | commentWidgetRegistry.addFactory( 42 | new TextSelectionCommentWidgetFactory( 43 | { 44 | commentRegistry, 45 | tracker: editorTracker 46 | }, 47 | manager 48 | ) 49 | ); 50 | 51 | const button = panel.button; 52 | 53 | app.commands.addCommand(CommandIDs.addComment, { 54 | label: 'Add Comment', 55 | execute: () => { 56 | let editorWidget = (shell.currentWidget as DocumentWidget) 57 | .content as CodeEditorWrapper; 58 | 59 | if (editorWidget == null) { 60 | return; 61 | } 62 | 63 | const model = panel.model; 64 | if (model == null) { 65 | return; 66 | } 67 | 68 | const comments = model.comments; 69 | let index = comments.length; 70 | let { start, end } = editorWidget.editor.getSelection(); 71 | //backwards selection compatibility 72 | if ( 73 | start.line > end.line || 74 | (start.line === end.line && start.column > end.column) 75 | ) { 76 | [start, end] = [end, start]; 77 | } 78 | 79 | for (let i = 0; i < comments.length; i++) { 80 | const comment = comments.get(i) as ITextSelectionComment; 81 | let sel = comment.target; 82 | let commentStart = sel.start; 83 | if ( 84 | start.line < commentStart.line || 85 | (start.line === commentStart.line && 86 | start.column <= commentStart.column) 87 | ) { 88 | index = i; 89 | break; 90 | } 91 | } 92 | 93 | panel.mockComment( 94 | { 95 | identity: getIdentity(model.awareness), 96 | type: 'text-selection', 97 | source: editorWidget 98 | }, 99 | index 100 | ); 101 | } 102 | }); 103 | 104 | // Ideally, the button should be anchored to the CodeMirrorEditor and scroll along with it. 105 | // However, when using the scroll element as the anchor, click events first register on the 106 | // editor, causing the awareness to update and the button to close without triggering the click 107 | // callback. For now, scrolling causes the button to close instead. 108 | function openButton(x: number, y: number, anchor: HTMLElement): void { 109 | const onScroll = () => button.close(); 110 | anchor.addEventListener('scroll', onScroll, { 111 | passive: true, 112 | once: true 113 | }); 114 | 115 | button.open(x, y, () => { 116 | void app.commands.execute(CommandIDs.addComment); 117 | anchor.removeEventListener('scroll', onScroll); 118 | }); 119 | } 120 | 121 | let currAwareness: Awareness | null = null; 122 | let handler: () => void; 123 | let onMouseup: (event: MouseEvent) => void; 124 | 125 | //commenting stuff for non-notebook/json files 126 | shell.currentChanged.connect(async (_, changed) => { 127 | if (currAwareness != null && handler != null && onMouseup != null) { 128 | document.removeEventListener('mouseup', onMouseup); 129 | currAwareness.off('change', handler); 130 | button.close(); 131 | } 132 | if (changed.newValue == null /*|| panel.model == null*/) { 133 | return; 134 | } 135 | const editorWidget = Private.getEditor(changed.newValue); 136 | if (editorWidget == null) { 137 | return; 138 | } 139 | 140 | if (!editorTracker.has(editorWidget)) { 141 | await editorTracker.add(editorWidget); 142 | } 143 | editorWidget.node.focus(); 144 | editorWidget.editor.focus(); 145 | 146 | onMouseup = (_: MouseEvent): void => { 147 | const { right } = editorWidget.node.getBoundingClientRect(); 148 | const { start, end } = editorWidget.editor.getSelection(); 149 | const coord1 = editorWidget.editor.getCoordinateForPosition(start); 150 | const coord2 = editorWidget.editor.getCoordinateForPosition(end); 151 | const node = editorWidget.node.getElementsByClassName( 152 | 'CodeMirror-scroll' 153 | )[0] as HTMLElement; 154 | openButton(right - 20, (coord1.top + coord2.bottom) / 2 - 10, node); 155 | }; 156 | 157 | handler = (): void => { 158 | const { start, end } = editorWidget.editor.getSelection(); 159 | 160 | if (start.column !== end.column || start.line !== end.line) { 161 | document.addEventListener('mouseup', onMouseup, { once: true }); 162 | } else { 163 | button.close(); 164 | } 165 | }; 166 | 167 | if (currAwareness != null) { 168 | currAwareness.off('change', handler); 169 | } 170 | 171 | currAwareness = (editorWidget.editor.model.sharedModel as YFile) 172 | .awareness; 173 | currAwareness.on('change', handler); 174 | }); 175 | 176 | app.contextMenu.addItem({ 177 | command: CommandIDs.addComment, 178 | selector: '.jp-FileEditorCodeWrapper' 179 | }); 180 | } 181 | }; 182 | 183 | namespace Private { 184 | export function getEditor(widget: Widget): CodeEditorWrapper | undefined { 185 | if (!widget.hasClass('jp-Document')) { 186 | return; 187 | } 188 | 189 | const content = (widget as DocumentWidget).content; 190 | if (!content.hasClass('jp-FileEditor')) { 191 | return; 192 | } 193 | 194 | return content as CodeEditorWrapper; 195 | } 196 | } 197 | 198 | export default textCommentingPlugin; 199 | -------------------------------------------------------------------------------- /src/api/header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ReactWidget, UseSignal } from '@jupyterlab/apputils'; 4 | 5 | import { getIdentity, setIdentityName } from './utils'; 6 | 7 | import { Awareness } from 'y-protocols/awareness'; 8 | 9 | import { editIcon, refreshIcon, saveIcon } from '@jupyterlab/ui-components'; 10 | 11 | import { ISignal, Signal } from '@lumino/signaling'; 12 | import { ILabShell } from '@jupyterlab/application'; 13 | import { CommentPanel } from './panel'; 14 | import { IChangedArgs } from '@jupyterlab/coreutils'; 15 | import { CommentFileWidget } from './widget'; 16 | import { CommentFileModel } from './model'; 17 | /** 18 | * This type comes from @jupyterlab/apputils/vdom.ts but isn't exported. 19 | */ 20 | type ReactRenderElement = 21 | | Array> 22 | | React.ReactElement; 23 | 24 | type IdentityProps = { 25 | awareness: Awareness | undefined; 26 | panel: CommentPanel; 27 | }; 28 | 29 | type FileTitleProps = { 30 | panel: CommentPanel; 31 | }; 32 | 33 | function FileTitle(props: FileTitleProps): JSX.Element { 34 | const panel = props.panel; 35 | 36 | const [isDirty, SetIsDirty] = React.useState(panel.model?.dirty ?? false); 37 | const [tooltip, SetTooltip] = React.useState( 38 | panel.fileWidget?.context.path ?? '' 39 | ); 40 | const [filename, SetFilename] = React.useState(panel.sourcePath ?? ''); 41 | 42 | const dirtySignalHandler = (_: any, change: IChangedArgs): void => { 43 | if (change.name === 'dirty') { 44 | SetIsDirty(change.newValue); 45 | } 46 | }; 47 | 48 | const pathChangedHandler = (_: any, newPath: string): void => { 49 | SetTooltip(panel.fileWidget?.context.path ?? ''); 50 | SetFilename(panel.sourcePath ?? ''); 51 | }; 52 | 53 | const modelChangedHandler = ( 54 | _: any, 55 | widget: CommentFileWidget | undefined 56 | ): void => { 57 | Signal.disconnectAll(dirtySignalHandler); 58 | 59 | SetTooltip(widget?.context.path ?? ''); 60 | SetFilename(panel.sourcePath ?? ''); 61 | 62 | if (widget == null) { 63 | return; 64 | } 65 | 66 | const model = widget.context.model as CommentFileModel; 67 | model.stateChanged.connect(dirtySignalHandler); 68 | }; 69 | 70 | React.useEffect(() => { 71 | panel.modelChanged.connect(modelChangedHandler); 72 | const fileWidget = panel.fileWidget; 73 | if (fileWidget != null) { 74 | fileWidget.context.pathChanged.connect(pathChangedHandler); 75 | } 76 | 77 | return () => { 78 | Signal.disconnectAll(modelChangedHandler); 79 | Signal.disconnectAll(pathChangedHandler); 80 | }; 81 | }); 82 | 83 | return ( 84 |
85 | {filename} 86 | {isDirty &&
} 87 |
88 | ); 89 | } 90 | 91 | function UserIdentity(props: IdentityProps): JSX.Element { 92 | const { awareness, panel } = props; 93 | const handleClick = () => { 94 | SetEditable(true); 95 | }; 96 | const [editable, SetEditable] = React.useState(false); 97 | 98 | const IdentityDiv = () => { 99 | if (awareness != undefined) { 100 | return ( 101 |
107 | {getIdentity(awareness).name} 108 |
109 | ); 110 | } 111 | }; 112 | 113 | const handleKeydown = (event: React.KeyboardEvent): void => { 114 | const target = event.target as HTMLDivElement; 115 | if (event.key === 'Escape') { 116 | SetEditable(false); 117 | target.blur(); 118 | return; 119 | } else if (event.key !== 'Enter') { 120 | return; 121 | } else if (event.shiftKey) { 122 | return; 123 | } 124 | event.preventDefault(); 125 | event.stopPropagation(); 126 | 127 | if (awareness != null) { 128 | const newName = target.textContent; 129 | if (newName == null || newName === '') { 130 | target.textContent = getIdentity(awareness).name; 131 | } else { 132 | setIdentityName(awareness, newName); 133 | panel.updateIdentity(awareness.clientID, newName); 134 | } 135 | } 136 | SetEditable(false); 137 | }; 138 | return ( 139 |
140 | {IdentityDiv()} 141 |
handleClick()}> 142 | 143 |
144 |
145 | ); 146 | } 147 | 148 | export class PanelHeader extends ReactWidget { 149 | constructor(options: PanelHeader.IOptions) { 150 | super(); 151 | const { panel } = options; 152 | this._panel = panel; 153 | this.addClass('jc-panelHeader'); 154 | } 155 | 156 | render(): ReactRenderElement { 157 | const refresh = () => { 158 | const fileWidget = this._panel.fileWidget; 159 | if (fileWidget == null) { 160 | return; 161 | } 162 | 163 | fileWidget.initialize(); 164 | }; 165 | 166 | const save = () => { 167 | const fileWidget = this._panel.fileWidget; 168 | if (fileWidget == null) { 169 | return; 170 | } 171 | 172 | void fileWidget.context.save(); 173 | refresh(); 174 | }; 175 | 176 | return ( 177 | 178 |
179 | 180 | {() => ( 181 | 182 | )} 183 | 184 | 185 |
186 | 187 |
188 | {/* Inline style added to align icons */} 189 |
194 | 195 |
196 |
197 | 198 |
199 |
200 |
201 | ); 202 | } 203 | 204 | /** 205 | * A signal emitted when a React re-render is required. 206 | */ 207 | get renderNeeded(): ISignal { 208 | return this._renderNeeded; 209 | } 210 | 211 | get awareness(): Awareness | undefined { 212 | return this._awareness; 213 | } 214 | set awareness(newValue: Awareness | undefined) { 215 | this._awareness = newValue; 216 | this._renderNeeded.emit(undefined); 217 | } 218 | 219 | private _awareness: Awareness | undefined; 220 | private _panel: CommentPanel; 221 | private _renderNeeded = new Signal(this); 222 | } 223 | 224 | export namespace PanelHeader { 225 | export interface IOptions { 226 | shell: ILabShell; 227 | panel: CommentPanel; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/api/plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ILabShell, 3 | JupyterFrontEnd, 4 | JupyterFrontEndPlugin 5 | } from '@jupyterlab/application'; 6 | 7 | import { WidgetTracker } from '@jupyterlab/apputils'; 8 | import { CommentPanel } from './panel'; 9 | import { CommentWidget } from './widget'; 10 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 11 | import { CommentRegistry, CommentWidgetRegistry } from './registry'; 12 | import { IDocumentManager } from '@jupyterlab/docmanager'; 13 | import { DocumentRegistry, DocumentWidget } from '@jupyterlab/docregistry'; 14 | import { Menu } from '@lumino/widgets'; 15 | import { CommentFileModelFactory } from './model'; 16 | import { 17 | ICommentPanel, 18 | ICommentRegistry, 19 | ICommentWidgetRegistry 20 | } from './token'; 21 | 22 | namespace CommandIDs { 23 | export const addComment = 'jl-comments:add-comment'; 24 | export const deleteComment = 'jl-comments:delete-comment'; 25 | export const editComment = 'jl-comments:edit-comment'; 26 | export const replyToComment = 'jl-comments:reply-to-comment'; 27 | export const save = 'jl-comments:save'; 28 | } 29 | 30 | export type CommentTracker = WidgetTracker>; 31 | 32 | /** 33 | * A plugin that provides a `CommentRegistry` 34 | */ 35 | export const commentRegistryPlugin: JupyterFrontEndPlugin = { 36 | id: 'jupyterlab-comments:comment-registry', 37 | autoStart: true, 38 | provides: ICommentRegistry, 39 | activate: (app: JupyterFrontEnd) => { 40 | return new CommentRegistry(); 41 | } 42 | }; 43 | 44 | /** 45 | * A plugin that provides a `CommentWidgetRegistry` 46 | */ 47 | export const commentWidgetRegistryPlugin: JupyterFrontEndPlugin = 48 | { 49 | id: 'jupyterlab-comments:comment-widget-registry', 50 | autoStart: true, 51 | provides: ICommentWidgetRegistry, 52 | activate: (app: JupyterFrontEnd) => { 53 | return new CommentWidgetRegistry(); 54 | } 55 | }; 56 | 57 | export const jupyterCommentingPlugin: JupyterFrontEndPlugin = { 58 | id: 'jupyterlab-comments:commenting-api', 59 | autoStart: true, 60 | requires: [ 61 | ICommentRegistry, 62 | ICommentWidgetRegistry, 63 | ILabShell, 64 | IDocumentManager, 65 | IRenderMimeRegistry 66 | ], 67 | provides: ICommentPanel, 68 | activate: ( 69 | app: JupyterFrontEnd, 70 | commentRegistry: ICommentRegistry, 71 | commentWidgetRegistry: ICommentWidgetRegistry, 72 | shell: ILabShell, 73 | docManager: IDocumentManager, 74 | renderer: IRenderMimeRegistry 75 | ): CommentPanel => { 76 | const filetype: DocumentRegistry.IFileType = { 77 | contentType: 'file', 78 | displayName: 'comment', 79 | extensions: ['.comment'], 80 | fileFormat: 'json', 81 | name: 'comment', 82 | mimeTypes: ['application/json'] 83 | }; 84 | 85 | const commentTracker = new WidgetTracker>({ 86 | namespace: 'comment-widgets' 87 | }); 88 | 89 | const panel = new CommentPanel({ 90 | commands: app.commands, 91 | commentRegistry, 92 | commentWidgetRegistry, 93 | docManager, 94 | shell, 95 | renderer 96 | }); 97 | 98 | // Create the directory holding the comments. 99 | void panel.pathExists(panel.pathPrefix).then(exists => { 100 | const contents = docManager.services.contents; 101 | if (!exists) { 102 | void contents 103 | .newUntitled({ 104 | path: '/', 105 | type: 'directory' 106 | }) 107 | .then(model => { 108 | void contents.rename(model.path, panel.pathPrefix); 109 | }); 110 | } 111 | }); 112 | 113 | addCommands(app, commentTracker, panel); 114 | 115 | const commentMenu = new Menu({ commands: app.commands }); 116 | commentMenu.addItem({ command: CommandIDs.deleteComment }); 117 | commentMenu.addItem({ command: CommandIDs.editComment }); 118 | commentMenu.addItem({ command: CommandIDs.replyToComment }); 119 | 120 | app.contextMenu.addItem({ 121 | command: CommandIDs.deleteComment, 122 | selector: '.jc-Comment' 123 | }); 124 | app.contextMenu.addItem({ 125 | command: CommandIDs.editComment, 126 | selector: '.jc-Comment' 127 | }); 128 | app.contextMenu.addItem({ 129 | command: CommandIDs.replyToComment, 130 | selector: '.jc-Comment' 131 | }); 132 | 133 | const modelFactory = new CommentFileModelFactory({ 134 | commentRegistry, 135 | commentWidgetRegistry, 136 | commentMenu 137 | }); 138 | 139 | app.docRegistry.addFileType(filetype); 140 | app.docRegistry.addModelFactory(modelFactory); 141 | 142 | // Add the panel to the shell's right area. 143 | shell.add(panel, 'right', { rank: 600 }); 144 | 145 | // Load model for current document when it changes 146 | shell.currentChanged.connect((_, args) => { 147 | if (args.newValue != null && args.newValue instanceof DocumentWidget) { 148 | const docWidget = args.newValue as DocumentWidget; 149 | docWidget.context.ready 150 | .then(() => { 151 | void panel.loadModel(docWidget.context); 152 | }) 153 | .catch(() => { 154 | console.warn('Unable to load panel'); 155 | }); 156 | } 157 | }); 158 | 159 | // Update comment widget tracker when model changes 160 | panel.modelChanged.connect((_, fileWidget) => { 161 | if (fileWidget != null) { 162 | fileWidget.widgets.forEach( 163 | widget => void commentTracker.add(widget as CommentWidget) 164 | ); 165 | fileWidget.commentAdded.connect( 166 | (_, commentWidget) => void commentTracker.add(commentWidget) 167 | ); 168 | } 169 | }); 170 | 171 | // Reveal the comment panel when a comment is added. 172 | panel.commentAdded.connect((_, comment) => { 173 | const identity = comment.identity; 174 | 175 | // If you didn't make the comment, ignore it 176 | // Comparing ids would be better but they're not synchronized across Docs/awarenesses 177 | if (identity == null || identity.name !== panel.localIdentity.name) { 178 | return; 179 | } 180 | 181 | // Automatically opens panel when a document with comments is opened, 182 | // or when the local user adds a new comment 183 | if (!panel.isVisible) { 184 | shell.activateById(panel.id); 185 | if (comment.text === '') { 186 | comment.openEditActive(); 187 | } 188 | } 189 | 190 | panel.scrollToComment(comment.id); 191 | }); 192 | 193 | app.contextMenu.addItem({ 194 | command: CommandIDs.save, 195 | selector: '.jc-CommentPanel' 196 | }); 197 | 198 | return panel; 199 | } 200 | }; 201 | 202 | function addCommands( 203 | app: JupyterFrontEnd, 204 | commentTracker: CommentTracker, 205 | panel: ICommentPanel 206 | ): void { 207 | app.commands.addCommand(CommandIDs.save, { 208 | label: 'Save Comments', 209 | execute: () => { 210 | const fileWidget = panel.fileWidget; 211 | if (fileWidget == null) { 212 | return; 213 | } 214 | 215 | void fileWidget.context.save(); 216 | } 217 | }); 218 | 219 | app.commands.addCommand(CommandIDs.deleteComment, { 220 | label: 'Delete Comment', 221 | execute: () => { 222 | const currentComment = commentTracker.currentWidget; 223 | if (currentComment != null) { 224 | currentComment.deleteActive(); 225 | } 226 | } 227 | }); 228 | 229 | app.commands.addCommand(CommandIDs.editComment, { 230 | label: 'Edit Comment', 231 | execute: () => { 232 | const currentComment = commentTracker.currentWidget; 233 | if (currentComment != null) { 234 | currentComment.openEditActive(); 235 | } 236 | } 237 | }); 238 | 239 | app.commands.addCommand(CommandIDs.replyToComment, { 240 | label: 'Reply to Comment', 241 | execute: () => { 242 | const currentComment = commentTracker.currentWidget; 243 | if (currentComment != null) { 244 | currentComment.revealReply(); 245 | } 246 | } 247 | }); 248 | } 249 | 250 | const plugins: JupyterFrontEndPlugin[] = [ 251 | jupyterCommentingPlugin, 252 | commentRegistryPlugin, 253 | commentWidgetRegistryPlugin 254 | ]; 255 | 256 | export default plugins; 257 | -------------------------------------------------------------------------------- /src/api/panel.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Panel, Widget } from '@lumino/widgets'; 2 | import { UUID } from '@lumino/coreutils'; 3 | import { Message } from '@lumino/messaging'; 4 | import { CommentFileWidget, CommentWidget } from './widget'; 5 | import { YDocument } from '@jupyterlab/shared-models'; 6 | import { ISignal, Signal } from '@lumino/signaling'; 7 | import { CommandRegistry } from '@lumino/commands'; 8 | import { Awareness } from 'y-protocols/awareness'; 9 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 10 | import { 11 | ICommentPanel, 12 | ICommentRegistry, 13 | ICommentWidgetRegistry 14 | } from './token'; 15 | import { ILabShell } from '@jupyterlab/application'; 16 | import { PanelHeader } from './header'; 17 | import { IDocumentManager } from '@jupyterlab/docmanager'; 18 | import { Context, DocumentRegistry } from '@jupyterlab/docregistry'; 19 | import { hashString, randomIdentity } from './utils'; 20 | import { CommentFileModel } from './model'; 21 | import { CommentsPanelIcon } from './icons'; 22 | import { NewCommentButton } from './button'; 23 | import { IIdentity } from './commentformat'; 24 | import { Contents } from '@jupyterlab/services'; 25 | 26 | export class CommentPanel extends Panel implements ICommentPanel { 27 | renderer: IRenderMimeRegistry; 28 | 29 | constructor(options: CommentPanel.IOptions) { 30 | super(); 31 | 32 | this.id = `CommentPanel-${UUID.uuid4()}`; 33 | this.title.icon = CommentsPanelIcon; 34 | this.addClass('jc-CommentPanel'); 35 | 36 | const { 37 | docManager, 38 | commentRegistry, 39 | commentWidgetRegistry, 40 | shell, 41 | renderer 42 | } = options; 43 | 44 | this._commentRegistry = commentRegistry; 45 | this._commentWidgetRegistry = commentWidgetRegistry; 46 | this._commentMenu = new Menu({ commands: options.commands }); 47 | this._docManager = docManager; 48 | 49 | const panelHeader: PanelHeader = new PanelHeader({ 50 | shell, 51 | panel: this 52 | }); 53 | 54 | this.addWidget(panelHeader as Widget); 55 | 56 | this._panelHeader = panelHeader; 57 | this.renderer = renderer; 58 | 59 | this._localIdentity = randomIdentity(); 60 | 61 | docManager.services.contents.fileChanged.connect(this._onFileChange, this); 62 | } 63 | 64 | private async _onFileChange( 65 | contents: Contents.IManager, 66 | change: Contents.IChangedArgs 67 | ): Promise { 68 | const sourcePath = change?.oldValue?.path; 69 | const commentsPath = 70 | sourcePath != null ? this.getCommentPathFor(sourcePath) : undefined; 71 | 72 | switch (change.type) { 73 | case 'delete': 74 | if (await this.pathExists(commentsPath!)) { 75 | return contents.delete(commentsPath!); 76 | } 77 | break; 78 | case 'rename': 79 | const newPath = change.newValue!.path!; 80 | if (!(await this.pathExists(commentsPath!))) { 81 | return; 82 | } 83 | const newCommentsPath = this.getCommentPathFor(newPath); 84 | if (this.sourcePath === sourcePath) { 85 | this._sourcePath = newPath; 86 | } 87 | return void contents.rename(commentsPath!, newCommentsPath); 88 | case 'save': 89 | if (this.sourcePath === change.newValue!.path!) { 90 | return this._fileWidget!.context.save(); 91 | } 92 | break; 93 | } 94 | } 95 | 96 | getCommentFileNameFor(sourcePath: string): string { 97 | return hashString(sourcePath).toString() + '.comment'; 98 | } 99 | 100 | getCommentPathFor(sourcePath: string): string { 101 | return this.pathPrefix + this.getCommentFileNameFor(sourcePath); 102 | } 103 | 104 | onUpdateRequest(msg: Message): void { 105 | if (this._fileWidget == null) { 106 | return; 107 | } 108 | 109 | const awareness = this.awareness; 110 | if (awareness != null && awareness !== this.panelHeader.awareness) { 111 | this.panelHeader.awareness = awareness; 112 | } 113 | } 114 | 115 | pathExists(path: string): Promise { 116 | const contents = this._docManager.services.contents; 117 | return contents 118 | .get(path, { content: false }) 119 | .then(() => true) 120 | .catch(() => false); 121 | } 122 | 123 | async getContext(path: string): Promise { 124 | const docManager = this._docManager; 125 | const factory = docManager.registry.getModelFactory('comment-file'); 126 | const preference = docManager.registry.getKernelPreference( 127 | path, 128 | 'comment-factory' 129 | ); 130 | 131 | const context: Context = 132 | // @ts-ignore 133 | docManager._findContext(path, 'comment-file') ?? 134 | // @ts-ignore 135 | docManager._createContext(path, factory, preference); 136 | 137 | await docManager.services.ready; 138 | const exists = await this.pathExists(path); 139 | void context.initialize(!exists); 140 | return context; 141 | } 142 | 143 | async loadModel( 144 | context: DocumentRegistry.IContext 145 | ): Promise { 146 | // Lock to prevent multiple loads at the same time. 147 | if (this._loadingModel) { 148 | return; 149 | } 150 | 151 | const sourcePath = context.path; 152 | // Attempting to load model for a non-document widget 153 | if ( 154 | sourcePath === '' || 155 | (this._sourcePath && this._sourcePath === sourcePath) 156 | ) { 157 | return; 158 | } 159 | 160 | this._sourcePath = sourcePath; 161 | 162 | this._loadingModel = true; 163 | 164 | if (this._fileWidget != null) { 165 | this.model!.changed.disconnect(this._onChange, this); 166 | const oldWidget = this._fileWidget; 167 | oldWidget.hide(); 168 | if (!oldWidget.context.isDisposed) { 169 | await oldWidget.context.save(); 170 | oldWidget.dispose(); 171 | } 172 | } 173 | 174 | const path = this.getCommentPathFor(sourcePath); 175 | 176 | const commentContext = await this.getContext(path); 177 | await commentContext.ready; 178 | 179 | const content = new CommentFileWidget( 180 | { context: commentContext }, 181 | this.renderer 182 | ); 183 | 184 | this._fileWidget = content; 185 | this.addWidget(content); 186 | 187 | content.commentAdded.connect((_, widget) => 188 | this._commentAdded.emit(widget) 189 | ); 190 | 191 | this.model!.changed.connect(this._onChange, this); 192 | 193 | const { name, color, icon } = this._localIdentity; 194 | this.model!.awareness.setLocalStateField('user', { 195 | name, 196 | color, 197 | icon 198 | }); 199 | 200 | this.update(); 201 | content.initialize(); 202 | this._modelChanged.emit(content); 203 | 204 | this._loadingModel = false; 205 | } 206 | 207 | private _onChange( 208 | _: CommentFileModel, 209 | changes: CommentFileModel.IChange[] 210 | ): void { 211 | const fileWidget = this.fileWidget; 212 | if (fileWidget == null) { 213 | return; 214 | } 215 | 216 | const widgets = fileWidget.widgets; 217 | let index = 0; 218 | 219 | for (let change of changes) { 220 | if (change.retain != null) { 221 | index += change.retain; 222 | } else if (change.insert != null) { 223 | change.insert.forEach(comment => 224 | fileWidget.insertComment(comment, index++) 225 | ); 226 | } else if (change.delete != null) { 227 | widgets 228 | .slice(index, index + change.delete) 229 | .forEach(widget => widget.dispose()); 230 | } else if (change.update != null) { 231 | for (let i = 0; i < change.update; i++) { 232 | widgets[index++].update(); 233 | } 234 | } 235 | } 236 | } 237 | 238 | get ymodel(): YDocument | undefined { 239 | if (this._fileWidget == null) { 240 | return; 241 | } 242 | return this._fileWidget.context.model.sharedModel as YDocument; 243 | } 244 | 245 | get model(): CommentFileModel | undefined { 246 | const docWidget = this._fileWidget; 247 | if (docWidget == null) { 248 | return; 249 | } 250 | return docWidget.model; 251 | } 252 | 253 | get fileWidget(): CommentFileWidget | undefined { 254 | return this._fileWidget; 255 | } 256 | 257 | get modelChanged(): ISignal { 258 | return this._modelChanged; 259 | } 260 | 261 | /** 262 | * Scroll the comment with the given id into view. 263 | */ 264 | scrollToComment(id: string): void { 265 | const node = document.getElementById(id); 266 | if (node == null) { 267 | return; 268 | } 269 | 270 | node.scrollIntoView({ behavior: 'smooth' }); 271 | } 272 | 273 | /** 274 | * Show the widget, make it visible to its parent widget, and emit the 275 | * `revealed` signal. 276 | * 277 | * ### Notes 278 | * This causes the [[isHidden]] property to be false. 279 | * If the widget is not explicitly hidden, this is a no-op. 280 | */ 281 | show(): void { 282 | if (this.isHidden) { 283 | this._revealed.emit(undefined); 284 | super.show(); 285 | } 286 | } 287 | 288 | /** 289 | * A signal emitted when a comment is added to the panel. 290 | */ 291 | get commentAdded(): Signal> { 292 | return this._commentAdded; 293 | } 294 | 295 | /** 296 | * The dropdown menu for comment widgets. 297 | */ 298 | get commentMenu(): Menu { 299 | return this._commentMenu; 300 | } 301 | 302 | /** 303 | * A signal emitted when the panel is about to be shown. 304 | */ 305 | get revealed(): Signal { 306 | return this._revealed; 307 | } 308 | 309 | get panelHeader(): PanelHeader { 310 | return this._panelHeader; 311 | } 312 | 313 | get awareness(): Awareness | undefined { 314 | return this.model?.awareness; 315 | } 316 | 317 | get commentRegistry(): ICommentRegistry { 318 | return this._commentRegistry; 319 | } 320 | 321 | get commentWidgetRegistry(): ICommentWidgetRegistry { 322 | return this._commentWidgetRegistry; 323 | } 324 | 325 | get pathPrefix(): string { 326 | return this._pathPrefix; 327 | } 328 | set pathPrefix(newValue: string) { 329 | this._pathPrefix = newValue; 330 | } 331 | 332 | get sourcePath(): string | null { 333 | return this._sourcePath; 334 | } 335 | 336 | mockComment( 337 | options: CommentFileWidget.IMockCommentOptions, 338 | index: number 339 | ): CommentWidget | undefined { 340 | const model = this.model; 341 | if (model == null) { 342 | return; 343 | } 344 | 345 | const commentFactory = this.commentRegistry.getFactory(options.type); 346 | if (commentFactory == null) { 347 | return; 348 | } 349 | 350 | const comment = commentFactory.createComment({ ...options, text: '' }); 351 | 352 | const widgetFactory = this.commentWidgetRegistry.getFactory(options.type); 353 | if (widgetFactory == null) { 354 | return; 355 | } 356 | 357 | const widget = widgetFactory.createWidget(comment, model, options.source); 358 | if (widget == null) { 359 | return; 360 | } 361 | 362 | widget.isMock = true; 363 | 364 | this.fileWidget!.insertWidget(index, widget); 365 | this._commentAdded.emit(widget); 366 | } 367 | 368 | updateIdentity(id: number, newName: string): void { 369 | this._localIdentity.name = newName; 370 | 371 | const model = this.model; 372 | if (model == null) { 373 | return; 374 | } 375 | 376 | model.comments.forEach(comment => { 377 | if (comment.identity.id === id) { 378 | model.editComment( 379 | { 380 | identity: { ...comment.identity, name: newName } 381 | }, 382 | comment.id 383 | ); 384 | } 385 | 386 | comment.replies.forEach(reply => { 387 | if (reply.identity.id === id) { 388 | model.editReply( 389 | { 390 | identity: { ...reply.identity, name: newName } 391 | }, 392 | reply.id, 393 | comment.id 394 | ); 395 | } 396 | }); 397 | }); 398 | 399 | this.update(); 400 | } 401 | 402 | get button(): NewCommentButton { 403 | return this._button; 404 | } 405 | 406 | get localIdentity(): IIdentity { 407 | return this._localIdentity; 408 | } 409 | 410 | private _commentAdded = new Signal>(this); 411 | private _revealed = new Signal(this); 412 | private _commentMenu: Menu; 413 | private _commentRegistry: ICommentRegistry; 414 | private _commentWidgetRegistry: ICommentWidgetRegistry; 415 | private _panelHeader: PanelHeader; 416 | private _fileWidget: CommentFileWidget | undefined = undefined; 417 | private _docManager: IDocumentManager; 418 | private _modelChanged = new Signal(this); 419 | private _pathPrefix: string = 'comments/'; 420 | private _button = new NewCommentButton(); 421 | private _loadingModel = false; 422 | private _localIdentity: IIdentity; 423 | private _sourcePath: string | null = null; 424 | } 425 | 426 | export namespace CommentPanel { 427 | export interface IOptions { 428 | docManager: IDocumentManager; 429 | commands: CommandRegistry; 430 | commentRegistry: ICommentRegistry; 431 | commentWidgetRegistry: ICommentWidgetRegistry; 432 | shell: ILabShell; 433 | renderer: IRenderMimeRegistry; 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/api/model.ts: -------------------------------------------------------------------------------- 1 | import { IComment, IIdentity, IReply } from './commentformat'; 2 | import { CommentFactory } from './factory'; 3 | import { ICommentRegistry, ICommentWidgetRegistry } from './token'; 4 | import { ISharedDocument, YDocument } from '@jupyterlab/shared-models'; 5 | import * as Y from 'yjs'; 6 | import { PartialJSONValue } from '@lumino/coreutils'; 7 | import { ISignal, Signal } from '@lumino/signaling'; 8 | import { Awareness } from 'y-protocols/awareness'; 9 | import { Menu } from '@lumino/widgets'; 10 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 11 | import { IModelDB, ModelDB } from '@jupyterlab/observables'; 12 | import { IChangedArgs } from '@jupyterlab/coreutils'; 13 | import { Contents } from '@jupyterlab/services'; 14 | import { CommentWidget } from './widget'; 15 | import { getCommentTimeStamp } from './utils'; 16 | 17 | /** 18 | * The default model for comment files. 19 | */ 20 | export class CommentFileModel implements DocumentRegistry.IModel { 21 | /** 22 | * Construct a new `CommentFileModel`. 23 | */ 24 | constructor(options: CommentFileModel.IOptions) { 25 | const { 26 | commentRegistry, 27 | commentWidgetRegistry, 28 | commentMenu, 29 | isInitialized 30 | } = options; 31 | 32 | this.commentRegistry = commentRegistry; 33 | this.commentWidgetRegistry = commentWidgetRegistry; 34 | this._commentMenu = commentMenu; 35 | this._isInitialized = isInitialized ?? false; 36 | 37 | this.comments.observeDeep(this._commentsObserver); 38 | } 39 | 40 | /** 41 | * Dispose of the model and its resources. 42 | */ 43 | dispose(): void { 44 | if (this._isDisposed) { 45 | return; 46 | } 47 | 48 | this._isDisposed = true; 49 | this.comments.unobserveDeep(this._commentsObserver); 50 | } 51 | 52 | widgets: readonly CommentWidget[] | undefined; 53 | 54 | /** 55 | * Serialize the model to JSON. 56 | */ 57 | toJSON(): PartialJSONValue { 58 | if (this.widgets == null) { 59 | console.warn( 60 | 'No comment widgets found for model. Serializing based on default IComment' 61 | ); 62 | return this.comments.toJSON(); 63 | } 64 | 65 | return this.widgets.map(widget => widget.toJSON()); 66 | } 67 | 68 | /** 69 | * Deserialize the model from JSON. 70 | */ 71 | fromJSON(value: PartialJSONValue): void { 72 | this.ymodel.transact(() => { 73 | const comments = this.comments; 74 | comments.delete(0, comments.length); 75 | comments.push(value as any as IComment[]); 76 | }); 77 | 78 | this._signalContentChange(); 79 | } 80 | 81 | /** 82 | * Serialize the model to a string. 83 | */ 84 | toString(): string { 85 | return JSON.stringify(this.toJSON(), undefined, 2); 86 | } 87 | 88 | /** 89 | * Deserialize the model from a string. 90 | */ 91 | fromString(value: string): void { 92 | this.fromJSON(JSON.parse(value !== '' ? value : '[]')); 93 | } 94 | 95 | private _commentsObserver = (events: Y.YEvent[]): void => { 96 | for (let event of events) { 97 | const delta = event.delta as CommentFileModel.IChange[]; 98 | 99 | // Converts a deletion followed by an insertion into an update 100 | // Normally, yjs doesn't propagate changes to the contents of a YArray, 101 | // only insertions and deletions. 102 | // Parsing a deletion/insertion pair into an update allows clients to 103 | // communicate when a comment has been changed over yjs. 104 | let lastInserted = 0; 105 | for (let i = 0; i < delta.length; i++) { 106 | let d = delta[i]; 107 | if (d.insert != null) { 108 | lastInserted = d.insert.length; 109 | } else if (d.delete != null) { 110 | if (lastInserted === d.delete) { 111 | delta.splice(i - 1, 2, { update: lastInserted }); 112 | } 113 | lastInserted = 0; 114 | } else { 115 | lastInserted = 0; 116 | } 117 | } 118 | 119 | this._changed.emit(delta); 120 | } 121 | }; 122 | 123 | private _updateComment(comment: IComment, index: number): void { 124 | const comments = this.comments; 125 | this.ymodel.ydoc.transact(() => { 126 | comments.delete(index); 127 | comments.insert(index, [comment]); 128 | }); 129 | 130 | this._signalContentChange(); 131 | } 132 | 133 | /** 134 | * Create a comment from an `ICommentOptions` object. 135 | * 136 | * ### Notes 137 | * This will fail if there's no factory for the given comment type. 138 | */ 139 | createComment(options: ICommentOptions): IComment | undefined { 140 | const factory = this.commentRegistry.getFactory(options.type); 141 | if (factory == null) { 142 | return; 143 | } 144 | 145 | return factory.createComment(options); 146 | } 147 | 148 | /** 149 | * Create a reply from an `IReplyOptions` object. 150 | */ 151 | createReply(options: Exclude): IReply { 152 | return CommentFactory.createReply(options); 153 | } 154 | 155 | /** 156 | * Create a comment from `options` and inserts it in `this.comments` at `index`. 157 | */ 158 | insertComment(options: ICommentOptions, index: number): void { 159 | const comment = this.createComment(options); 160 | if (comment == null) { 161 | return; 162 | } 163 | 164 | this.comments.insert(index, [comment]); 165 | // Delta emitted by listener 166 | this._signalContentChange(); 167 | } 168 | 169 | /** 170 | * Creates a comment from `options` and inserts it at the end of `this.comments`. 171 | */ 172 | addComment(options: ICommentOptions): void { 173 | const comment = this.createComment(options); 174 | if (comment == null) { 175 | return; 176 | } 177 | 178 | this.comments.push([comment]); 179 | // Delta emitted by listener 180 | this._signalContentChange(); 181 | } 182 | 183 | /** 184 | * Creates a reply from `options` and inserts it in the replies of the comment 185 | * with id `parentID` at `index`. 186 | */ 187 | insertReply(options: IReplyOptions, parentID: string, index: number): void { 188 | const loc = this.getComment(parentID); 189 | if (loc == null) { 190 | return; 191 | } 192 | 193 | const reply = this.createReply(options); 194 | const newComment = { ...loc.comment }; 195 | newComment.replies.splice(index, 0, reply); 196 | this._updateComment(newComment, loc.index); 197 | } 198 | 199 | /** 200 | * Creates a reply from `options` and appends it to the replies of the comment 201 | * with id `parentID`. 202 | */ 203 | addReply(options: IReplyOptions, parentID: string): void { 204 | const loc = this.getComment(parentID); 205 | if (loc == null) { 206 | return; 207 | } 208 | 209 | const reply = this.createReply(options); 210 | const newComment = { ...loc.comment }; 211 | newComment.replies.push(reply); 212 | this._updateComment(newComment, loc.index); 213 | } 214 | 215 | /** 216 | * Deletes the comment with id `id` from `this.comments`. 217 | */ 218 | deleteComment(id: string): void { 219 | const loc = this.getComment(id); 220 | if (loc == null) { 221 | return; 222 | } 223 | 224 | this.comments.delete(loc.index); 225 | // Delta emitted by listener 226 | this._signalContentChange(); 227 | } 228 | 229 | /** 230 | * Deletes the reply with id `id` from `this.comments`. 231 | * 232 | * If a `parentID` is given, it will be used to locate the parent comment. 233 | * Otherwise, all comments will be searched for the reply with the given id. 234 | */ 235 | deleteReply(id: string, parentID?: string): void { 236 | const loc = this.getReply(id, parentID); 237 | if (loc == null) { 238 | return; 239 | } 240 | 241 | const newComment = { ...loc.parent }; 242 | newComment.replies.splice(loc.index, 1); 243 | this._updateComment(newComment, loc.parentIndex); 244 | } 245 | 246 | /** 247 | * Applies the changes in `options` to the comment with id `id`. 248 | */ 249 | editComment( 250 | options: Partial>, 251 | id: string 252 | ): void { 253 | const loc = this.getComment(id); 254 | if (loc == null) { 255 | return; 256 | } 257 | options.editedTime = getCommentTimeStamp() 258 | 259 | const newComment = { ...loc.comment, ...options }; 260 | this._updateComment(newComment, loc.index); 261 | } 262 | 263 | /** 264 | * Applies the changes in `options` to the reply with id `id`. 265 | * 266 | * If a `parentID` is given, it will be used to locate the parent comment. 267 | * Otherwise, all comments will be searched for the reply with the given id. 268 | */ 269 | editReply( 270 | options: Partial>, 271 | id: string, 272 | parentID?: string 273 | ): void { 274 | const loc = this.getReply(id, parentID); 275 | if (loc == null) { 276 | return; 277 | } 278 | 279 | options.editedTime = getCommentTimeStamp() 280 | Object.assign(loc.reply, loc.reply, options); 281 | const newComment = { ...loc.parent }; 282 | this._updateComment(newComment, loc.parentIndex); 283 | } 284 | 285 | /** 286 | * Get the comment with id `id`. Returns undefined if not found. 287 | */ 288 | getComment(id: string): CommentFileModel.ICommentLocation | undefined { 289 | const comments = this.comments; 290 | for (let i = 0; i < comments.length; i++) { 291 | const comment = comments.get(i); 292 | if (comment.id === id) { 293 | return { 294 | index: i, 295 | comment 296 | }; 297 | } 298 | } 299 | 300 | return; 301 | } 302 | 303 | /** 304 | * Returns the reply with id `id`. Returns undefined if not found. 305 | * 306 | * If a `parentID` is given, it will be used to locate the parent comment. 307 | * Otherwise, all comments will be searched for the reply with the given id. 308 | */ 309 | getReply( 310 | id: string, 311 | parentID?: string 312 | ): CommentFileModel.IReplyLocation | undefined { 313 | let parentIndex: number; 314 | let parent: IComment; 315 | 316 | if (parentID != null) { 317 | const parentLocation = this.getComment(parentID); 318 | if (parentLocation == null) { 319 | return; 320 | } 321 | 322 | parentIndex = parentLocation.index; 323 | parent = parentLocation.comment; 324 | 325 | for (let i = 0; i < parent.replies.length; i++) { 326 | const reply = parent.replies[i]; 327 | if (reply.id === id) { 328 | return { 329 | parentIndex, 330 | parent, 331 | reply, 332 | index: i 333 | }; 334 | } 335 | } 336 | 337 | return; 338 | } 339 | 340 | const comments = this.comments; 341 | for (let i = 0; i < comments.length; i++) { 342 | const parent = comments.get(i); 343 | for (let j = 0; i < parent.replies.length; i++) { 344 | const reply = parent.replies[j]; 345 | if (reply.id === id) { 346 | return { 347 | parentIndex: i, 348 | parent, 349 | reply, 350 | index: j 351 | }; 352 | } 353 | } 354 | } 355 | 356 | return; 357 | } 358 | 359 | initialize(): void { 360 | this.sharedModel.clearUndoHistory(); 361 | this._isInitialized = true; 362 | } 363 | 364 | /** 365 | * The comments associated with the model. 366 | */ 367 | get comments(): Y.Array { 368 | return this.ymodel.ydoc.getArray('comments'); 369 | } 370 | 371 | /** 372 | * The registry containing the comment factories needed to create the model's comments. 373 | */ 374 | readonly commentRegistry: ICommentRegistry; 375 | 376 | readonly commentWidgetRegistry: ICommentWidgetRegistry; 377 | 378 | /** 379 | * The underlying model handling RTC between clients. 380 | */ 381 | readonly ymodel = new YDocument(); 382 | 383 | /** 384 | * The awareness associated with the document being commented on. 385 | */ 386 | get awareness(): Awareness { 387 | return this.ymodel.awareness; 388 | } 389 | 390 | /** 391 | * The dropdown menu for comment widgets. 392 | */ 393 | get commentMenu(): Menu | undefined { 394 | return this._commentMenu; 395 | } 396 | 397 | /** 398 | * TODO: A signal emitted when the model is changed. 399 | * See the notes on `CommentFileModel.IChange` below. 400 | */ 401 | get changed(): ISignal { 402 | return this._changed; 403 | } 404 | 405 | get sharedModel(): ISharedDocument { 406 | return this.ymodel; 407 | } 408 | 409 | get readOnly(): boolean { 410 | return this._readOnly; 411 | } 412 | set readOnly(newVal: boolean) { 413 | const oldVal = this.readOnly; 414 | if (newVal !== oldVal) { 415 | this._readOnly = newVal; 416 | this._signalStateChange(oldVal, newVal, 'readOnly'); 417 | } 418 | } 419 | 420 | get dirty(): boolean { 421 | return this._dirty; 422 | } 423 | set dirty(newVal: boolean) { 424 | const oldVal = this.dirty; 425 | if (newVal !== oldVal) { 426 | this._dirty = newVal; 427 | this._signalStateChange(oldVal, newVal, 'dirty'); 428 | } 429 | } 430 | 431 | get stateChanged(): ISignal> { 432 | return this._stateChanged; 433 | } 434 | 435 | get contentChanged(): ISignal { 436 | return this._contentChanged; 437 | } 438 | 439 | get isDisposed(): boolean { 440 | return this._isDisposed; 441 | } 442 | 443 | get isInitialized(): boolean { 444 | return this._isInitialized; 445 | } 446 | 447 | private _signalStateChange(oldValue: any, newValue: any, name: string): void { 448 | this._stateChanged.emit({ 449 | oldValue, 450 | newValue, 451 | name 452 | }); 453 | } 454 | 455 | private _signalContentChange(): void { 456 | this.dirty = true; 457 | this._contentChanged.emit(); 458 | } 459 | 460 | // These are never used--just here to satisfy the interface requirements. 461 | readonly modelDB: IModelDB = new ModelDB(); 462 | readonly defaultKernelLanguage = ''; 463 | readonly defaultKernelName = ''; 464 | 465 | private _isInitialized: boolean; 466 | private _dirty: boolean = false; 467 | private _readOnly: boolean = false; 468 | private _isDisposed: boolean = false; 469 | private _commentMenu: Menu | undefined; 470 | private _changed = new Signal(this); 471 | private _stateChanged = new Signal>(this); 472 | private _contentChanged = new Signal(this); 473 | } 474 | 475 | export namespace CommentFileModel { 476 | export interface IOptions { 477 | commentRegistry: ICommentRegistry; 478 | commentWidgetRegistry: ICommentWidgetRegistry; 479 | isInitialized?: boolean; 480 | commentMenu?: Menu; 481 | } 482 | 483 | /** 484 | * TODO: An interface that describes a change to a model. 485 | * This will be filled out once `YArrayEvent` is better understood. 486 | */ 487 | export interface IChange { 488 | insert?: IComment[]; 489 | retain?: number; 490 | delete?: number; 491 | update?: number; 492 | } 493 | 494 | export interface ICommentLocation { 495 | index: number; 496 | comment: IComment; 497 | } 498 | 499 | export interface IReplyLocation { 500 | parentIndex: number; 501 | index: number; 502 | parent: IComment; 503 | reply: IReply; 504 | } 505 | } 506 | 507 | export class CommentFileModelFactory 508 | implements DocumentRegistry.IModelFactory 509 | { 510 | constructor(options: CommentFileModelFactory.IOptions) { 511 | const { commentRegistry, commentWidgetRegistry, commentMenu } = options; 512 | 513 | this._commentRegistry = commentRegistry; 514 | this._commentWidgetRegistry = commentWidgetRegistry; 515 | this._commentMenu = commentMenu; 516 | } 517 | 518 | readonly name: string = 'comment-file'; 519 | readonly contentType: Contents.ContentType = 'file'; 520 | readonly fileFormat: Contents.FileFormat = 'text'; 521 | 522 | createNew( 523 | languagePreference?: string, 524 | modelDB?: IModelDB, 525 | isInitialized?: boolean 526 | ): CommentFileModel { 527 | const commentRegistry = this._commentRegistry; 528 | const commentWidgetRegistry = this._commentWidgetRegistry; 529 | const commentMenu = this._commentMenu; 530 | return new CommentFileModel({ 531 | commentRegistry, 532 | commentWidgetRegistry, 533 | commentMenu, 534 | isInitialized 535 | }); 536 | } 537 | 538 | preferredLanguage(path: string): string { 539 | return ''; 540 | } 541 | 542 | dispose(): void { 543 | this._isDisposed = true; 544 | } 545 | 546 | get isDisposed(): boolean { 547 | return this._isDisposed; 548 | } 549 | 550 | private _commentRegistry: ICommentRegistry; 551 | private _commentWidgetRegistry: ICommentWidgetRegistry; 552 | private _commentMenu: Menu; 553 | private _isDisposed = false; 554 | } 555 | 556 | export namespace CommentFileModelFactory { 557 | export interface IOptions { 558 | commentRegistry: ICommentRegistry; 559 | commentWidgetRegistry: ICommentWidgetRegistry; 560 | commentMenu: Menu; 561 | } 562 | } 563 | 564 | /** 565 | * Options object for creating a comment. 566 | */ 567 | export interface ICommentOptions { 568 | text: string; 569 | identity: IIdentity; 570 | type: string; 571 | replies?: IReply[]; 572 | id?: string; // defaults to UUID.uuid4(); 573 | editedTime?: string; 574 | source: any; 575 | } 576 | 577 | /** 578 | * Options object for creating a reply. 579 | */ 580 | export interface IReplyOptions { 581 | text: string; 582 | identity: IIdentity; 583 | editedTime?: string; 584 | id?: string; // defaults to UUID.uuid4() 585 | } 586 | -------------------------------------------------------------------------------- /demo/hh_demo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "04cb7b92-7e6f-4f79-a230-708566434dcb", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "import hh\n", 11 | "import numpy as np\n", 12 | "import matplotlib.pyplot as plt\n", 13 | "from scipy.integrate import odeint " 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": 2, 19 | "id": "37cea923-eb19-4ac5-a6a5-92d4d4e07e88", 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "eqns = hh.HodgkinHuxley()" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 3, 29 | "id": "d49d3d71-9fa3-4592-a094-0fcad6ec771c", 30 | "metadata": {}, 31 | "outputs": [ 32 | { 33 | "data": { 34 | "image/png": "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\n", 35 | "text/plain": [ 36 | "
" 37 | ] 38 | }, 39 | "metadata": { 40 | "needs_background": "light" 41 | }, 42 | "output_type": "display_data" 43 | } 44 | ], 45 | "source": [ 46 | "Y = np.array([0.0, eqns.n_inf(), eqns.m_inf(), eqns.h_inf()])\n", 47 | "Vy = odeint(eqns.derivatives, Y, eqns.t)\n", 48 | "V = Vy[:,0]\n", 49 | "m = Vy[:,1]\n", 50 | "h = Vy[:,2]\n", 51 | "n = Vy[:,3]\n", 52 | "\n", 53 | "plt.figure()\n", 54 | "\n", 55 | "plt.subplot(4,1,1)\n", 56 | "plt.title('Hodgkin-Huxley for space-clamped axon')\n", 57 | "plt.plot(eqns.t, V, 'k')\n", 58 | "plt.ylabel('V (mV)')\n", 59 | "plt.xlabel('Time (ms)')\n", 60 | "\n", 61 | "plt.subplot(4,1,3)\n", 62 | "plt.plot(eqns.t, m, 'r', label='m')\n", 63 | "plt.plot(eqns.t, h, 'g', label='h')\n", 64 | "plt.plot(eqns.t, n, 'b', label='n')\n", 65 | "plt.ylabel('Gating Value')\n", 66 | "plt.xlabel('Time (ms)')\n", 67 | "plt.legend()\n", 68 | "\n", 69 | "plt.show()" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": null, 75 | "id": "d391cffd-c1ca-4ba3-a8b3-e177691b09b1", 76 | "metadata": {}, 77 | "outputs": [], 78 | "source": [] 79 | } 80 | ], 81 | "metadata": { 82 | "kernelspec": { 83 | "display_name": "Python 3 (ipykernel)", 84 | "language": "python", 85 | "name": "python3" 86 | }, 87 | "language_info": { 88 | "codemirror_mode": { 89 | "name": "ipython", 90 | "version": 3 91 | }, 92 | "file_extension": ".py", 93 | "mimetype": "text/x-python", 94 | "name": "python", 95 | "nbconvert_exporter": "python", 96 | "pygments_lexer": "ipython3", 97 | "version": "3.9.4" 98 | } 99 | }, 100 | "nbformat": 4, 101 | "nbformat_minor": 5 102 | } 103 | --------------------------------------------------------------------------------