Skip to content
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

feat: Cleanup automerge codemirror plugin #4914

Merged
merged 20 commits into from Dec 13, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -123,6 +123,7 @@
"typescript": "^5.2.2",
"uuid": "^8.3.2",
"vite": "^4.3.9",
"vite-plugin-top-level-await": "^1.4.1",
"vitest": "^0.31.2",
"wait-for-expect": "^3.0.2"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/apps/plugins/plugin-chain/package.json
Expand Up @@ -25,7 +25,7 @@
"dependencies": {
"@braneframe/plugin-stack": "workspace:*",
"@braneframe/types": "workspace:*",
"@codemirror/language": "^6.9.2",
"@codemirror/language": "^6.9.3",
"@dxos/chain": "workspace:*",
"@dxos/invariant": "workspace:*",
"@dxos/react-client": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/apps/plugins/plugin-thread/package.json
Expand Up @@ -23,7 +23,7 @@
"src"
],
"dependencies": {
"@codemirror/language": "^6.9.2",
"@codemirror/language": "^6.9.3",
"@dxos/display-name": "workspace:*",
"@dxos/react-client": "workspace:*",
"@dxos/react-ui-editor": "workspace:*",
Expand Down
19 changes: 9 additions & 10 deletions packages/ui/react-ui-editor/package.json
Expand Up @@ -16,16 +16,16 @@
"prebuild": "dxtype src/testing/proto/schema.proto src/testing/proto/gen/schema.ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.11.0",
"@codemirror/commands": "^6.3.0",
"@codemirror/lang-markdown": "^6.2.2",
"@codemirror/language": "^6.9.2",
"@codemirror/autocomplete": "^6.11.1",
"@codemirror/commands": "^6.3.2",
"@codemirror/lang-markdown": "^6.2.3",
"@codemirror/language": "^6.9.3",
"@codemirror/language-data": "^6.3.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.3.1",
"@codemirror/search": "^6.5.5",
"@codemirror/state": "^6.3.3",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.22.0",
"@codemirror/view": "^6.22.2",
"@dxos/debug": "workspace:*",
"@dxos/display-name": "workspace:*",
"@dxos/log": "workspace:*",
Expand All @@ -49,7 +49,7 @@
"@tiptap/pm": "2.0.0-beta.220",
"@tiptap/react": "2.0.0-beta.220",
"@tiptap/starter-kit": "2.0.0-beta.220",
"codemirror": "^6.65.7",
"codemirror": "6.0.1",
"lib0": "^0.2.65",
"lodash.get": "^4.4.2",
"prosemirror-model": "^1.19.0",
Expand All @@ -62,15 +62,14 @@
"yjs": "^13.6.10"
},
"devDependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@automerge/automerge-repo-network-broadcastchannel": "^1.0.19",
"@dxos/automerge": "workspace:*",
"@dxos/config": "workspace:*",
"@dxos/echo-schema": "workspace:*",
"@dxos/echo-typegen": "workspace:*",
"@dxos/react-client": "workspace:*",
"@dxos/storybook-utils": "workspace:*",
"@phosphor-icons/react": "^2.0.5",
"@types/codemirror": "^5.60.14",
"@types/lodash.get": "^4.4.7",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
Expand Down
Expand Up @@ -8,8 +8,8 @@ import { next as automerge } from '@dxos/automerge/automerge';

import amToCodemirror from './amToCodemirror';
import codeMirrorToAm from './codeMirrorToAm';
import { type IDocHandle } from './handle';
import { type Field, isReconcileTx, getPath, reconcileAnnotationType, updateHeads, getLastHeads } from './plugin';
import { type Peer } from '../demo';

type Doc<T> = automerge.Doc<T>;
type Heads = automerge.Heads;
Expand All @@ -27,8 +27,8 @@ export class PatchSemaphore {
}
}

reconcile = (handle: Peer, view: EditorView) => {
if (!this._inReconcile) {
reconcile = (handle: IDocHandle, view: EditorView) => {
if (this._inReconcile) {
return;
}
this._inReconcile = true;
Expand Down Expand Up @@ -56,12 +56,12 @@ export class PatchSemaphore {
// NOTE: null and undefined each come from automerge and repo respectively
if (newHeads === null || newHeads === undefined) {
// TODO: @alexjg this is the call that's resetting the editor state on click
newHeads = automerge.getHeads(handle.doc);
newHeads = automerge.getHeads(handle.docSync()!);
}

// now get the diff between the updated state of the document and the heads
// and apply that to the codemirror doc
const diff = automerge.equals(oldHeads, newHeads) ? [] : automerge.diff(handle.doc, oldHeads, newHeads);
const diff = automerge.equals(oldHeads, newHeads) ? [] : automerge.diff(handle.docSync()!, oldHeads, newHeads);
amToCodemirror(view, selection, path, diff);

view.dispatch({
Expand Down
Expand Up @@ -6,21 +6,24 @@ import { type EditorState, type Text, type Transaction } from '@codemirror/state

import { next as am, type Heads } from '@dxos/automerge/automerge';

import { type IDocHandle } from './handle';
import { type Field } from './plugin';
import { type Peer } from '../demo';

export default (field: Field, handle: Peer, transactions: Transaction[], state: EditorState): Heads | undefined => {
export default (
field: Field,
handle: IDocHandle,
transactions: Transaction[],
state: EditorState,
): Heads | undefined => {
const { lastHeads, path } = state.field(field);

// We don't want to call `automerge.updateAt` if there are no changes.
// Otherwise later on `automerge.diff` will return empty patches that result in a no-op but still mess up the selection.
let hasChanges = false;
for (const tr of transactions) {
if (tr.changes.length) {
tr.changes.iterChanges(() => {
hasChanges = true;
});
}
tr.changes.iterChanges(() => {
hasChanges = true;
});
}

if (!hasChanges) {
Expand Down
@@ -0,0 +1,14 @@
//
// Copyright 2023 DXOS.org
//

import type * as A from '@dxos/automerge/automerge';

export type IDocHandle<T = any> = {
docSync(): A.Doc<T> | undefined;
change(callback: A.ChangeFn<T>, options?: A.ChangeOptions<T>): void;
changeAt(heads: A.Heads, callback: A.ChangeFn<T>, options?: A.ChangeOptions<T>): string[] | undefined;

addListener(event: 'change', listener: () => void): void;
removeListener(event: 'change', listener: () => void): void;
};
Expand Up @@ -2,5 +2,5 @@
// Copyright 2023 DXOS.org
//

export { plugin, reconcile } from './plugin';
export { PatchSemaphore } from './PatchSemaphore';
export { automergePlugin, type AutomergePlugin } from './plugin';
export { type IDocHandle } from './handle';
Expand Up @@ -12,13 +12,13 @@ import {
type Transaction,
type TransactionSpec,
} from '@codemirror/state';
import { type EditorView } from '@codemirror/view';
import { ViewPlugin, type EditorView, type PluginValue, type ViewUpdate } from '@codemirror/view';

import * as automerge from '@dxos/automerge/automerge';
import { type Doc, type Heads, type Prop } from '@dxos/automerge/automerge';
import { type Heads, type Prop } from '@dxos/automerge/automerge';

import { PatchSemaphore } from './PatchSemaphore';
import { type Peer } from '../demo';
import { type IDocHandle } from './handle';

export type Value = {
lastHeads: Heads;
Expand All @@ -44,10 +44,14 @@ const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
combine: (values) => values.at(-1)!, // Take last.
});

export const plugin = <T>(doc: Doc<T>, path: Prop[]): Extension => {
export type AutomergePlugin = {
extension: Extension;
};

export const automergePlugin = (handle: IDocHandle, path: Prop[]): AutomergePlugin => {
const stateField: StateField<Value> = StateField.define({
create: () => ({
lastHeads: automerge.getHeads(doc),
lastHeads: automerge.getHeads(handle.docSync()!),
unreconciledTransactions: [],
path: path.slice(),
}),
Expand Down Expand Up @@ -76,7 +80,36 @@ export const plugin = <T>(doc: Doc<T>, path: Prop[]): Extension => {
});
const semaphore = new PatchSemaphore(stateField);

return [stateField, semaphoreFacet.of(semaphore)];
const viewPlugin = ViewPlugin.fromClass(
class AutomergeCodemirrorViewPlugin implements PluginValue {
private _view: EditorView;

constructor(view: EditorView) {
this._view = view;
handle.addListener('change', this._handleChange);
}

update(update: ViewUpdate) {
if (update.transactions.length > 0 && update.transactions.some((t) => !isReconcileTx(t))) {
queueMicrotask(() => {
this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
});
}
}

destroy() {
handle.addListener('change', this._handleChange);
}

private _handleChange = () => {
this._view.state.facet(semaphoreFacet).reconcile(handle, this._view);
};
},
);

return {
extension: [stateField, semaphoreFacet.of(semaphore), viewPlugin],
};
};

export const reconcileAnnotationType = Annotation.define<unknown>();
Expand All @@ -98,7 +131,3 @@ export const makeReconcile = (tr: TransactionSpec) => {
// annotations: reconcileAnnotationType.of({})
// }
};

export const reconcile = (handle: Peer, view: EditorView) => {
view.state.facet(semaphoreFacet).reconcile(handle, view);
};
77 changes: 31 additions & 46 deletions packages/ui/react-ui-editor/src/automerge/automerge.stories.tsx
Expand Up @@ -2,17 +2,20 @@
// Copyright 2023 DXOS.org
//

import { basicSetup } from '@codemirror/basic-setup';
import '@preact/signals-react'; // Register react integration
import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-network-broadcastchannel';
import { EditorView } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import get from 'lodash.get';
import React, { useEffect, useRef, useState } from 'react';

import { type Prop, next as automerge } from '@dxos/automerge/automerge';
import { type Prop } from '@dxos/automerge/automerge';
import { type DocHandle, Repo } from '@dxos/automerge/automerge-repo';

import { plugin as amgPlugin, reconcile } from './automerge-plugin';
import { Peer } from './demo';
import { type IDocHandle, automergePlugin } from './automerge-plugin';

type EditorProps = {
handle: Peer;
handle: IDocHandle;
path: Prop[];
};

Expand All @@ -21,27 +24,13 @@ const Editor = ({ handle, path }: EditorProps) => {
const editorRoot = useRef<EditorView>();

useEffect(() => {
const doc = handle.doc;
const source = doc.text; // this should use path
const plugin = amgPlugin(doc, path);
const view = (editorRoot.current = new EditorView({
doc: source,
extensions: [basicSetup, plugin],
dispatch: (transaction) => {
view.update([transaction]);
reconcile(handle, view);
},
doc: get(handle.docSync()!, path),
extensions: [basicSetup, automergePlugin(handle, path)],
parent: containerRef.current as any,
}));

const handleChange = () => {
reconcile(handle, view);
};

handle.changeEvent.on(handleChange);

return () => {
handle.changeEvent.off(handleChange);
view.destroy();
};
}, []);
Expand All @@ -50,31 +39,29 @@ const Editor = ({ handle, path }: EditorProps) => {
};

const Story = () => {
const [object1, setObject1] = useState<Peer | null>(null);
const [object2, setObject2] = useState<Peer | null>(null);
const [stats1, setStats1] = useState<any>({});
const [stats2, setStats2] = useState<any>({});
const [object1, setObject1] = useState<DocHandle<any> | null>(null);
const [object2, setObject2] = useState<DocHandle<any> | null>(null);

useEffect(() => {
const object1 = new Peer();
object1.doc = automerge.from({ text: 'Hello world!' });

const object2 = new Peer();
object2.doc = automerge.init();

const r1 = object1.replicate();
const r2 = object2.replicate();

void r1.readable.pipeTo(r2.writable);
void r2.readable.pipeTo(r1.writable);

setObject1(object1);
setObject2(object2);

setInterval(() => {
setStats1({ ...object1.stats });
setStats2({ ...object2.stats });
}, 500);
queueMicrotask(async () => {
const repo1 = new Repo({
network: [new BroadcastChannelNetworkAdapter()],
});
const repo2 = new Repo({
network: [new BroadcastChannelNetworkAdapter()],
});

const object1 = repo1.create();
object1.change((doc: any) => {
doc.text = 'Hello world!';
});

const object2 = repo2.find(object1.url);
await object2.whenReady();

setObject1(object1);
setObject2(object2);
});
}, []);

if (!object1 || !object2) {
Expand All @@ -85,11 +72,9 @@ const Story = () => {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', width: '100vw' }}>
<div>
<Editor handle={object1} path={['text']} />
<div style={{ whiteSpace: 'pre' }}>{JSON.stringify(stats1, null, 2)}</div>
</div>
<div>
<Editor handle={object2} path={['text']} />
<div style={{ whiteSpace: 'pre' }}>{JSON.stringify(stats2, null, 2)}</div>
</div>
</div>
);
Expand Down