Skip to content

Commit

Permalink
feat: implement undo/redo (#138)
Browse files Browse the repository at this point in the history
* feat: implement undo redo

* chore: changeset

* chore: lint

* feat: improve history

* feat: update collaboration provider to use new changeset api

* chore: lockfile

* chore: api

* chore: move

* fix: ignore adding remote changes to history

* chore: cleanup

* feat: add owner property in observer

* chore: add test
  • Loading branch information
prevwong authored Jan 1, 2024
1 parent ecb43d5 commit 7b6d6cb
Show file tree
Hide file tree
Showing 17 changed files with 1,006 additions and 95 deletions.
6 changes: 6 additions & 0 deletions .changeset/shaggy-mangos-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rekajs/collaboration': patch
'@rekajs/core': patch
---

Implement undo/redo
82 changes: 37 additions & 45 deletions packages/collaboration/src/YjsRekaSyncProvider.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import { Reka, ChangeListenerPayload } from '@rekajs/core';
import { Reka, Changeset } from '@rekajs/core';
import * as t from '@rekajs/types';
import { invariant } from '@rekajs/utils';
import { getRandomId, invariant } from '@rekajs/utils';
import * as Y from 'yjs';

import { getTypePathFromMobxChangePath, jsToYType, yTypeToJS } from './utils';

export class YjsRekaSyncProvider {
private mobxChangesToSync: ChangeListenerPayload[] = [];
private isBatchingMobxChanges = false;
private isSynchingToMobx = false;
id: string;

private declare rekaChangeUnsubscriber: () => void;

private yDocChangeListener: (
events: Y.YEvent<any>[],
tr: Y.Transaction
) => void;

private yDoc: Y.Doc;

constructor(readonly reka: Reka, readonly type: Y.Map<any>) {
constructor(readonly reka: Reka, readonly type: Y.Map<any>, id?: string) {
this.id = id ?? getRandomId();

if (!type.doc) {
throw new Error();
}
Expand All @@ -38,9 +39,17 @@ export class YjsRekaSyncProvider {
return;
}

this.withMobxSync(() => {
events.forEach((event) => this.syncToState(event));
});
this.reka.change(
() => {
events.forEach((event) => this.syncYEventToState(event));
},
{
source: this.changesetSourceKey,
history: {
ignore: true,
},
}
);
};
}

Expand All @@ -49,20 +58,16 @@ export class YjsRekaSyncProvider {
this.rekaChangeUnsubscriber();
}

private withMobxSync(cb: () => void) {
const prev = this.isSynchingToMobx;
this.isSynchingToMobx = true;
this.reka.change(() => {
cb();
});
this.isSynchingToMobx = prev;
}

get yRekaDocument() {
return this.type.get('document');
}

syncToState(event: Y.YEvent<any>) {
get changesetSourceKey() {
return `yjs-sync-${this.id}`;
}

syncYEventToState(event: Y.YEvent<any>) {
// No need to do anything if the event is from the sync mechanism below
if (event.transaction.origin === this) {
return;
}
Expand Down Expand Up @@ -140,7 +145,7 @@ export class YjsRekaSyncProvider {
}
}

syncMobxChangesToYDoc(changes: ChangeListenerPayload[]) {
syncStateChangesetToYDoc(changeset: Changeset) {
Y.transact(
this.yDoc,
() => {
Expand All @@ -149,17 +154,12 @@ export class YjsRekaSyncProvider {

const yDocRoot = this.yRekaDocument;

changes.forEach((change) => {
if (change.event === 'add') {
return;
}

if (change.event === 'dispose') {
const typeIdToDispose = change.type.id;
yDocRoot.get('types').delete(typeIdToDispose);
return;
}
changeset.disposed.forEach((dispose) => {
const typeIdToDispose = dispose.id;
yDocRoot.get('types').delete(typeIdToDispose);
});

changeset.changes.forEach((change) => {
const path = getTypePathFromMobxChangePath([...change.path]);

const getTypeFromId = (id: string) => {
Expand Down Expand Up @@ -302,28 +302,20 @@ export class YjsRekaSyncProvider {
}

init() {
// Listen to Y.js doc changes
this.type.observeDeep(this.yDocChangeListener);

// Listen to Reka state changes
this.rekaChangeUnsubscriber = this.reka.listenToChanges((change) => {
if (this.isSynchingToMobx) {
this.rekaChangeUnsubscriber = this.reka.listenToChangeset((changeset) => {
if (changeset.source === this.changesetSourceKey) {
return;
}

this.mobxChangesToSync.push(change);

if (this.isBatchingMobxChanges) {
if (changeset.changes.length === 0) {
return;
}

this.isBatchingMobxChanges = true;

Promise.resolve().then(() => {
this.syncMobxChangesToYDoc(this.mobxChangesToSync);
this.mobxChangesToSync = [];
this.isBatchingMobxChanges = false;
});
this.syncStateChangesetToYDoc(changeset);
});

// Listen to Y.js doc changes
this.type.observeDeep(this.yDocChangeListener);
}
}
2 changes: 1 addition & 1 deletion packages/collaboration/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { invariant } from '@rekajs/utils';
import * as Y from 'yjs';

import { YjsRekaSyncProvider } from './YjsRekaSyncProvider';
import { RekaToYSyncProviders } from './store';

const RekaToYSyncProviders = new WeakMap();

export const createCollabExtension = (type: Y.Map<any>) =>
createExtension({
Expand Down
5 changes: 5 additions & 0 deletions packages/collaboration/src/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Reka } from '@rekajs/core';

import { YjsRekaSyncProvider } from './YjsRekaSyncProvider';

export const RekaToYSyncProviders = new WeakMap<Reka, YjsRekaSyncProvider>();
4 changes: 4 additions & 0 deletions packages/core/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ export class ComponentViewEvaluator {
}

private reset() {
if (this.rekaComponentRootComputation) {
this.rekaComponentRootComputation.dispose();
}

this.componentViewTreeComputation = null;
this.rekaComponentRootComputation = null;
this.rekaComponentPropsComputation = null;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/history/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './manager';
export * from './manager-default';
Loading

1 comment on commit 7b6d6cb

@vercel
Copy link

@vercel vercel bot commented on 7b6d6cb Jan 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

reka – ./

reka-prevwong.vercel.app
reka-git-main-prevwong.vercel.app
rekajs.vercel.app
reka.js.org

Please sign in to comment.