Skip to content

Fix document example #272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 39 additions & 36 deletions documents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ 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`.:

```json5
// package.json#L108-L113

"sharedPackages": {
"@jupyter/docprovider": {
"@jupyter/collaborative-drive": {
"bundled": true,
"singleton": true
}
Expand Down Expand Up @@ -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,... .

Expand All @@ -255,7 +255,7 @@ In this extension, we created:

<!-- prettier-ignore-start -->
```ts
// src/model.ts#L354-L354
// src/model.ts#L344-L344

export class ExampleDoc extends YDocument<ExampleDocChange> {
```
Expand All @@ -265,7 +265,7 @@ To create a new shared object, you have to use the `ydoc`. The new attribute wil

<!-- prettier-ignore-start -->
```ts
// src/model.ts#L358-L359
// src/model.ts#L348-L349

this._content = this.ydoc.getMap('content');
this._content.observe(this._contentObserver);
Expand All @@ -278,7 +278,7 @@ we provide helpers `get` and `set` to hide the complexity of `position` being st

<!-- prettier-ignore-start -->
```ts
// src/model.ts#L390-L399
// src/model.ts#L408-L417

get(key: 'content'): string;
get(key: 'position'): Position;
Expand All @@ -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;
Expand All @@ -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();
```
Expand All @@ -335,11 +335,11 @@ Every time you modify a shared property, this property triggers an event in all

<!-- prettier-ignore-start -->
```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);
});
```
<!-- prettier-ignore-end -->
Expand All @@ -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

Expand All @@ -368,9 +368,9 @@ The shared map is added to the model like this:

<!-- prettier-ignore-start -->
```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)
```
<!-- prettier-ignore-end -->

Expand All @@ -379,23 +379,19 @@ must be defined:

<!-- prettier-ignore-start -->
```py
# jupyterlab_examples_documents/document.py#L16-L49
# jupyterlab_examples_documents/document.py#L18-L49

def get(self) -> str:
"""
Returns the content of the document as saved by the contents manager.

: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:
Expand All @@ -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"])
#
```
<!-- prettier-ignore-end -->
Expand All @@ -423,7 +421,7 @@ reacting to a document changes:

<!-- prettier-ignore-start -->
```py
# jupyterlab_examples_documents/document.py#L51-L60
# jupyterlab_examples_documents/document.py#L51-L65

def observe(self, callback: "Callable[[str, Any], None]") -> None:
"""
Expand All @@ -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")
)

#
```
<!-- prettier-ignore-end -->
Expand Down
41 changes: 23 additions & 18 deletions documents/jupyterlab_examples_documents/document.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
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:
"""
Returns the content of the document as saved by the contents manager.

: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:
Expand All @@ -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:
Expand All @@ -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")
)

#
6 changes: 3 additions & 3 deletions documents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -106,7 +106,7 @@
"extension": true,
"outputDir": "jupyterlab_examples_documents/labextension",
"sharedPackages": {
"@jupyter/docprovider": {
"@jupyter/collaborative-drive": {
"bundled": true,
"singleton": true
}
Expand Down
2 changes: 1 addition & 1 deletion documents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
2 changes: 1 addition & 1 deletion documents/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ICollaborativeDrive } from '@jupyter/docprovider';
import { ICollaborativeDrive } from '@jupyter/collaborative-drive';

import {
JupyterFrontEnd,
Expand Down
44 changes: 31 additions & 13 deletions documents/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -361,6 +351,34 @@ export class ExampleDoc extends YDocument<ExampleDocChange> {

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.
*/
Expand Down Expand Up @@ -395,7 +413,7 @@ export class ExampleDoc extends YDocument<ExampleDocChange> {
? data
? JSON.parse(data)
: { x: 0, y: 0 }
: data ?? '';
: (data ?? '');
}

/**
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading