diff --git a/documents/README.md b/documents/README.md index dcf5cc96..c072f315 100644 --- a/documents/README.md +++ b/documents/README.md @@ -28,10 +28,10 @@ Developers can provide new extensions to support additional documents (or replac The model, the shared model and the view will be provided through new factories and the file type will be registered directly. For that you will need to access the [`DocumentRegistry`](https://jupyterlab.readthedocs.io/en/latest/api/classes/docregistry.DocumentRegistry-1.html) to register new [`FileType`s](https://jupyterlab.readthedocs.io/en/latest/api/interfaces/rendermime_interfaces.IRenderMime.IFileType.html), models and views. This way, when opening a new file, the [`DocumentManager`](https://jupyterlab.readthedocs.io/en/latest/api/classes/docmanager.DocumentManager-1.html) will look into the file metadata and create an instance of `Context` with the right model for this file. To register new documents, you can create factories, either a [`IModelFactory`](https://jupyterlab.readthedocs.io/en/latest/api/interfaces/docregistry.DocumentRegistry.IModelFactory.html) for the model and/or a [`IWidgetFactory`](https://jupyterlab.readthedocs.io/en/latest/api/interfaces/docregistry.DocumentRegistry.IWidgetFactory.html) for the view. -The shared model needs to be registered only if your file must be collaborative. For that you will need to register it in the [`ICollaborativeDrive`](https://jupyterlab-realtime-collaboration.readthedocs.io/en/latest/api/interfaces/docprovider.ICollaborativeDrive.html) token provided by the `@jupyter/docprovider` package. +The shared model needs to be registered only if your file must be collaborative. For that you will need to register it in the [`ICollaborativeDrive`](https://jupyterlab-realtime-collaboration.readthedocs.io/en/latest/api/interfaces/collaborative_drive.ICollaborativeDrive.html) token provided by the `@jupyter/collaborative-drive` package. > Packaging note: when using an optional external extension (here -> `@jupyter/docprovider` from `jupyter-collaboration`), you must +> `@jupyter/collaborative-drive` from `jupyter-collaboration`), you must > tell JupyterLab to include that package in the current extension by > adding the following configuration in `package.json`.: @@ -39,7 +39,7 @@ The shared model needs to be registered only if your file must be collaborative. // package.json#L108-L113 "sharedPackages": { - "@jupyter/docprovider": { + "@jupyter/collaborative-drive": { "bundled": true, "singleton": true } @@ -229,7 +229,7 @@ The `DocumentModel` represents the file content in the frontend. Through the mod ## Make it collaborative -In JupyterLab v3.1, we introduced the package `@jupyterlab/shared-models` to swap `ModelDB` as a data storage to make the notebooks collaborative. We implemented these shared models using [Yjs](https://yjs.dev), a high-performance CRDT for building collaborative applications that automatically sync. You can find all the documentation of Yjs [here](https://docs.yjs.dev). +In JupyterLab v3.1, we switched from `ModelDB` as a data storage to shared models. We implemented these shared models using [Yjs](https://yjs.dev), a high-performance CRDT for building collaborative applications that automatically sync. You can find all the documentation of Yjs [here](https://docs.yjs.dev). Yjs documents (`Y.Doc`) are the main class of Yjs. They represent a shared document between clients and hold multiple shared objects. Yjs documents enable you to share different [data types like text, Array, Map or set](https://docs.yjs.dev/getting-started/working-with-shared-types), which makes it possible to create not only collaborative text editors but also diagrams, drawings,... . @@ -255,7 +255,7 @@ In this extension, we created: ```ts -// src/model.ts#L354-L354 +// src/model.ts#L344-L344 export class ExampleDoc extends YDocument { ``` @@ -265,7 +265,7 @@ To create a new shared object, you have to use the `ydoc`. The new attribute wil ```ts -// src/model.ts#L358-L359 +// src/model.ts#L348-L349 this._content = this.ydoc.getMap('content'); this._content.observe(this._contentObserver); @@ -278,7 +278,7 @@ we provide helpers `get` and `set` to hide the complexity of `position` being st ```ts -// src/model.ts#L390-L399 +// src/model.ts#L408-L417 get(key: 'content'): string; get(key: 'position'): Position; @@ -288,12 +288,12 @@ get(key: string): any { ? data ? JSON.parse(data) : { x: 0, y: 0 } - : data ?? ''; + : (data ?? ''); } ``` ```ts -// src/model.ts#L407-L411 +// src/model.ts#L425-L429 set(key: 'content', value: string): void; set(key: 'position', value: PartialJSONObject): void; @@ -315,7 +315,7 @@ this.sharedModel.awareness.setLocalStateField('mouse', pos); ``` ```ts -// src/model.ts#L289-L289 +// src/model.ts#L279-L279 const clients = this.sharedModel.awareness.getStates(); ``` @@ -335,11 +335,11 @@ Every time you modify a shared property, this property triggers an event in all ```ts -// src/model.ts#L233-L236 +// src/model.ts#L376-L379 -this.sharedModel.transact(() => { - this.sharedModel.set('position', { x: obj.x, y: obj.y }); - this.sharedModel.set('content', obj.content); +this.transact(() => { + this.set('position', { x: obj.x, y: obj.y }); + this.set('content', obj.content); }); ``` @@ -350,13 +350,13 @@ That client is responsible for loading, saving and watching the file on disk and to propagate all changes to all clients. This makes collaboration much more robust in case of flaky connection, file rename,... . -In Python, Yjs protocol is implemented in the library [`y-py`](https://github.com/y-crdt/ypy). But as we provide `@jupyterlab/shared-models` helpers for the frontend, we +In Python, Yjs protocol is implemented in the library [`ycrdt`](https://github.com/jupyter-server/pycrdt). But as we provide `@jupyter/ydoc` helpers for the frontend, we provide [`jupyter-ydoc`](https://github.com/jupyter-server/jupyter_ydoc) helpers for Python. A shared model must inherit from `YBaseDoc`, here: ```py -# jupyterlab_examples_documents/document.py#L4-L7 +# jupyterlab_examples_documents/document.py#L6-L9 from jupyter_ydoc.ybasedoc import YBaseDoc @@ -368,9 +368,9 @@ The shared map is added to the model like this: ```py -# jupyterlab_examples_documents/document.py#L10-L10 +# jupyterlab_examples_documents/document.py#L12-L12 -self._content = self._ydoc.get_map('content') +self._content = self._ydoc.get("content", type=pycrdt.Map) ``` @@ -379,7 +379,7 @@ must be defined: ```py -# jupyterlab_examples_documents/document.py#L16-L49 +# jupyterlab_examples_documents/document.py#L18-L49 def get(self) -> str: """ @@ -387,15 +387,11 @@ def get(self) -> str: :return: Document's content. """ - data = json.loads(self._content.to_json()) + data = self._content.to_py() position = json.loads(data["position"]) return json.dumps( - { - "x": position["x"], - "y": position["y"], - "content": data["content"] - }, - indent=2 + {"x": position["x"], "y": position["y"], "content": data["content"]}, + indent=2, ) def set(self, raw_value: str) -> None: @@ -405,15 +401,17 @@ def set(self, raw_value: str) -> None: :param raw_value: The content of the document. """ value = json.loads(raw_value) - with self._ydoc.begin_transaction() as t: + with self._ydoc.transaction(): # clear document - for key in self._content: - self._content.pop(t, key) - for key in [k for k in self._ystate if k not in ("dirty", "path")]: - self._ystate.pop(t, key) + for key in self._content.keys(): + self._content.pop(key) + for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]: + self._ystate.pop(key) + + self._content["position"] = {"x": value["x"], "y": value["y"]} + + self._content["content"] = value["content"] - self._content.set(t, "position", json.dumps({"x": value["x"], "y": value["y"]})) - self._content.set(t, "content", value["content"]) # ``` @@ -423,7 +421,7 @@ reacting to a document changes: ```py -# jupyterlab_examples_documents/document.py#L51-L60 +# jupyterlab_examples_documents/document.py#L51-L65 def observe(self, callback: "Callable[[str, Any], None]") -> None: """ @@ -432,8 +430,13 @@ def observe(self, callback: "Callable[[str, Any], None]") -> None: :param callback: Callback that will be called when the document changes. """ self.unobserve() - self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) - self._subscriptions[self._content] = self._content.observe(partial(callback, "content")) + self._subscriptions[self._ystate] = self._ystate.observe( + partial(callback, "state") + ) + self._subscriptions[self._content] = self._content.observe( + partial(callback, "content") + ) + # ``` diff --git a/documents/jupyterlab_examples_documents/document.py b/documents/jupyterlab_examples_documents/document.py index b589664c..88fb64f8 100644 --- a/documents/jupyterlab_examples_documents/document.py +++ b/documents/jupyterlab_examples_documents/document.py @@ -1,17 +1,19 @@ import json from functools import partial +from typing import Any, Callable +import pycrdt from jupyter_ydoc.ybasedoc import YBaseDoc class YExampleDoc(YBaseDoc): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._content = self._ydoc.get_map('content') + self._content = self._ydoc.get("content", type=pycrdt.Map) @property def version(self) -> str: - return '0.1.0' + return "0.1.0" def get(self) -> str: """ @@ -19,15 +21,11 @@ def get(self) -> str: :return: Document's content. """ - data = json.loads(self._content.to_json()) + data = self._content.to_py() position = json.loads(data["position"]) return json.dumps( - { - "x": position["x"], - "y": position["y"], - "content": data["content"] - }, - indent=2 + {"x": position["x"], "y": position["y"], "content": data["content"]}, + indent=2, ) def set(self, raw_value: str) -> None: @@ -37,15 +35,17 @@ def set(self, raw_value: str) -> None: :param raw_value: The content of the document. """ value = json.loads(raw_value) - with self._ydoc.begin_transaction() as t: + with self._ydoc.transaction(): # clear document - for key in self._content: - self._content.pop(t, key) - for key in [k for k in self._ystate if k not in ("dirty", "path")]: - self._ystate.pop(t, key) + for key in self._content.keys(): + self._content.pop(key) + for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]: + self._ystate.pop(key) + + self._content["position"] = {"x": value["x"], "y": value["y"]} + + self._content["content"] = value["content"] - self._content.set(t, "position", json.dumps({"x": value["x"], "y": value["y"]})) - self._content.set(t, "content", value["content"]) # def observe(self, callback: "Callable[[str, Any], None]") -> None: @@ -55,6 +55,11 @@ def observe(self, callback: "Callable[[str, Any], None]") -> None: :param callback: Callback that will be called when the document changes. """ self.unobserve() - self._subscriptions[self._ystate] = self._ystate.observe(partial(callback, "state")) - self._subscriptions[self._content] = self._content.observe(partial(callback, "content")) + self._subscriptions[self._ystate] = self._ystate.observe( + partial(callback, "state") + ) + self._subscriptions[self._content] = self._content.observe( + partial(callback, "content") + ) + # diff --git a/documents/package.json b/documents/package.json index db8642a5..d8a952a2 100644 --- a/documents/package.json +++ b/documents/package.json @@ -52,8 +52,8 @@ "watch:labextension": "jupyter labextension watch ." }, "dependencies": { - "@jupyter/docprovider": "^1.0.0", - "@jupyter/ydoc": "^1.0.0", + "@jupyter/collaborative-drive": "^3.0.0", + "@jupyter/ydoc": "^3.0.0", "@jupyterlab/application": "^4.0.0", "@jupyterlab/apputils": "^4.0.0", "@jupyterlab/coreutils": "^6.0.0", @@ -106,7 +106,7 @@ "extension": true, "outputDir": "jupyterlab_examples_documents/labextension", "sharedPackages": { - "@jupyter/docprovider": { + "@jupyter/collaborative-drive": { "bundled": true, "singleton": true } diff --git a/documents/pyproject.toml b/documents/pyproject.toml index f1732f63..a1322e1d 100644 --- a/documents/pyproject.toml +++ b/documents/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ - "jupyter_ydoc>=1.0.1,<2.0.0" + "jupyter_ydoc>=3.0.0,<4.0.0" ] dynamic = ["version", "description", "authors", "urls", "keywords"] diff --git a/documents/src/index.ts b/documents/src/index.ts index eec7dcb3..e8f1a14e 100644 --- a/documents/src/index.ts +++ b/documents/src/index.ts @@ -1,4 +1,4 @@ -import { ICollaborativeDrive } from '@jupyter/docprovider'; +import { ICollaborativeDrive } from '@jupyter/collaborative-drive'; import { JupyterFrontEnd, diff --git a/documents/src/model.ts b/documents/src/model.ts index 41a2c72a..a94d3c8a 100644 --- a/documents/src/model.ts +++ b/documents/src/model.ts @@ -212,13 +212,7 @@ export class ExampleDocModel implements DocumentRegistry.IModel { * @returns The data */ toString(): string { - const pos = this.sharedModel.get('position'); - const obj = { - x: pos?.x ?? 10, - y: pos?.y ?? 10, - content: this.sharedModel.get('content') ?? '' - }; - return JSON.stringify(obj, null, 2); + return this.sharedModel.getSource(); } /** @@ -229,11 +223,7 @@ export class ExampleDocModel implements DocumentRegistry.IModel { * @param data Serialized data */ fromString(data: string): void { - const obj = JSON.parse(data); - this.sharedModel.transact(() => { - this.sharedModel.set('position', { x: obj.x, y: obj.y }); - this.sharedModel.set('content', obj.content); - }); + this.sharedModel.setSource(data); } /** @@ -361,6 +351,34 @@ export class ExampleDoc extends YDocument { readonly version: string = '1.0.0'; + /** + * Get the document source + * + * @returns The source + */ + getSource(): string { + const pos = this.get('position'); + const obj = { + x: pos?.x ?? 10, + y: pos?.y ?? 10, + content: this.get('content') ?? '' + }; + return JSON.stringify(obj, null, 2); + } + + /** + * Set the document source + * + * @param value The source to set + */ + setSource(value: string): void { + const obj = JSON.parse(value); + this.transact(() => { + this.set('position', { x: obj.x, y: obj.y }); + this.set('content', obj.content); + }); + } + /** * Dispose of the resources. */ @@ -395,7 +413,7 @@ export class ExampleDoc extends YDocument { ? data ? JSON.parse(data) : { x: 0, y: 0 } - : data ?? ''; + : (data ?? ''); } /** diff --git a/documents/ui-tests/tests/documents.spec.ts-snapshots/documents-example-linux.png b/documents/ui-tests/tests/documents.spec.ts-snapshots/documents-example-linux.png index 67a52b4a..8d9aab8a 100644 Binary files a/documents/ui-tests/tests/documents.spec.ts-snapshots/documents-example-linux.png and b/documents/ui-tests/tests/documents.spec.ts-snapshots/documents-example-linux.png differ diff --git a/environment.yml b/environment.yml index f0c5a332..6728829a 100644 --- a/environment.yml +++ b/environment.yml @@ -2,8 +2,8 @@ name: jupyterlab-extension-examples channels: - conda-forge dependencies: - - jupyterlab >=4.0.0 - - nodejs=18 + - jupyterlab >=4.3.0 + - nodejs=22 - pytest - pytest-check-links - pytest-jupyter >=0.6.0 diff --git a/package.json b/package.json index df3cbb33..fa69ab2a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "devDependencies": { "embedme": "^1.22.1", "husky": "^8.0.3", - "lerna": "^7.4.2", + "lerna": "^8.1.9", "lint-staged": "^15.1.0", "prettier": "^3.0.3" }