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

Support multi-user undo and redo #635

Closed
wants to merge 42 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
887d4cf
add TODO requirement comments
hyemmie Aug 24, 2023
15393ab
add test code for initial undo/redo
hyemmie Aug 25, 2023
738ce5f
add history at document
hyemmie Aug 25, 2023
6e69505
feat reverse operation for counter increase
hyemmie Aug 25, 2023
98bc22e
feat undo function
hyemmie Aug 25, 2023
baf514a
temp for test
hyemmie Aug 25, 2023
3063ac6
Fix update() function
hyemmie Aug 28, 2023
f1c47f0
Add TODO comment for undefined executedAt
hyemmie Aug 28, 2023
554f0d2
Feat redo function
hyemmie Aug 28, 2023
9fe1c14
Add redo and no change test cases
hyemmie Aug 28, 2023
69cc857
Add undo/redo error handling and test code
hyemmie Aug 28, 2023
2fbae1e
Add assertUndoRedo and handle undo/redo for Long type
chacha912 Aug 28, 2023
3c81cc4
Add max stack size for undo/redo and test code
hyemmie Aug 28, 2023
a64ed15
Merge branch 'feat/undo-redo-arch' of https://github.com/yorkie-team/…
hyemmie Aug 28, 2023
dc1fd74
Fix long counter multiply function type error
hyemmie Aug 28, 2023
6c36a3c
Add test for reverse operation of increase operation
chacha912 Aug 28, 2023
6790ee1
Add test for changeInfo when undoing increase operation
chacha912 Aug 28, 2023
fe2400c
Add redo stack clear function and test code
hyemmie Aug 28, 2023
eeea054
Merge branch 'feat/undo-redo-arch' of https://github.com/yorkie-team/…
chacha912 Aug 28, 2023
cee8126
Implement undo/redo with presence
chacha912 Aug 31, 2023
48f7769
Merge branch 'main' of https://github.com/yorkie-team/yorkie-js-sdk i…
chacha912 Sep 1, 2023
ccb371a
Feat reverse operation of object set, remove
hyemmie Sep 1, 2023
5344222
Update test code for object undo/redo
hyemmie Sep 1, 2023
6261f54
Add concurrent object undo/redo tests
hyemmie Sep 4, 2023
99d48a7
Add codemirror devtool
chacha912 Sep 3, 2023
95b4217
Fix set reverse operation bug
hyemmie Sep 5, 2023
7b0d604
Add assertUndoRedo to object_test
hyemmie Sep 5, 2023
c62a0b9
Update document_test
hyemmie Sep 5, 2023
3d18f1c
Merge branch 'feat/undo-redo-arch' of https://github.com/yorkie-team/…
chacha912 Sep 5, 2023
550ce85
Add reverse operation of text.edit
chacha912 Sep 5, 2023
3b2e3b1
Add undo/redo in codemirror example
chacha912 Sep 5, 2023
38238e4
Add test cases that fail to share for debugging
chacha912 Sep 5, 2023
2a9c0bb
Cleanup test code
chacha912 Sep 6, 2023
e6aa29e
Implement the reverse operation of text.edit
hyemmie Sep 8, 2023
76370be
Update protocol buffer
hyemmie Sep 11, 2023
32304d0
Add concurrent test cases of text.edit undo
hyemmie Sep 11, 2023
7e4ecf0
Cleanup undo/redo test for simple object and nested object
chacha912 Sep 13, 2023
7ddba3c
Modify to execute reverse operations in reverse order within change
chacha912 Sep 13, 2023
1639835
Modify to enable creating CRDTElement with same createdAt
chacha912 Sep 14, 2023
3dfb748
Merge branch 'feat/undo-redo-arch' of https://github.com/yorkie-team/…
chacha912 Sep 19, 2023
fd6ae03
Merge branch 'main' of https://github.com/yorkie-team/yorkie-js-sdk i…
chacha912 Sep 19, 2023
a9c5d2f
Add undo and redo shortcuts to CodeMirror example
chacha912 Sep 19, 2023
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
6 changes: 6 additions & 0 deletions public/devtool/text.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@
height: 100%;
}

.layout > .content > .editor-area .editor-control {
position: absolute;
bottom: 0;
right: 0;
}

.layout > .content > .editor-area > .data-area {
display: flex;
}
Expand Down
56 changes: 51 additions & 5 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<div class="layout">
<div class="toolbar">
<div class="left-tools tools">
<!-- yorkie logo -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
Expand Down Expand Up @@ -116,6 +117,10 @@
<div class="editor-area">
<div class="codemirror-area">
<textarea id="textarea" cols="30" rows="10"></textarea>
<div class="editor-control">
<button class="undo-button">undo</button>
<button class="redo-button">redo</button>
</div>
</div>
<div class="data-area" id="view-text">
<div class="text-view-area">
Expand Down Expand Up @@ -367,9 +372,24 @@ <h4 class="title">
displayRemoteSelection(codemirror, doc, event.value);
}
});
doc.subscribe('my-presence', (event) => {
if (event.type === 'presence-changed') {
const [from, to] = doc
.getRoot()
.content.posRangeToIndexRange(event.value.presence.selection);

// TODO(chacha912): Update my selection when the selection
// changes through undo and redo.
console.log('my selection', from, to);
}
});

doc.subscribe('$.content', (event) => {
if (event.type === 'remote-change') {
if (
event.type === 'remote-change' ||
(event.type === 'local-change' &&
event.value.message === 'YORKIE_HISTORY')
) {
const { actor, operations } = event.value;
handleOperations(operations, actor);

Expand Down Expand Up @@ -397,9 +417,12 @@ <h4 class="title">

doc.update((root, presence) => {
const range = root.content.edit(from, to, content);
presence.set({
selection: root.content.indexRangeToPosRange(range),
});
presence.set(
{
selection: root.content.indexRangeToPosRange(range),
},
{ addToHistory: true },
);
}, `update content by ${client.getID()}`);
console.log(`%c local: ${from}-${to}: ${content}`, 'color: green');
});
Expand Down Expand Up @@ -428,7 +451,6 @@ <h4 class="title">

const from = cm.indexFromPos(change.ranges[0].anchor);
const to = cm.indexFromPos(change.ranges[0].head);

doc.update((root, presence) => {
presence.set({
selection: root.content.indexRangeToPosRange([from, to]),
Expand All @@ -455,6 +477,30 @@ <h4 class="title">
}
}

// Undo and Redo
document
.querySelector('.undo-button')
.addEventListener('click', () => {
console.log('undo op', doc.getUndoStackForTest().at(-1));
doc.undo();
});
document
.querySelector('.redo-button')
.addEventListener('click', () => {
console.log('redo op', doc.getRedoStackForTest().at(-1));
doc.redo();
});
codemirror.addKeyMap({
'Cmd-Z': (cm) => {
console.log('undo op', doc.getUndoStackForTest().at(-1));
doc.history.undo();
},
'Shift-Cmd-Z': (cm) => {
console.log('redo op', doc.getRedoStackForTest().at(-1));
doc.history.redo();
},
});

// 05. synchronize text of document and codemirror.
codemirror.setValue(doc.getRoot().content.toString());
devtool.displayLog(doc, codemirror);
Expand Down
79 changes: 68 additions & 11 deletions src/api/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AddOperation } from '@yorkie-js-sdk/src/document/operation/add_operatio
import { MoveOperation } from '@yorkie-js-sdk/src/document/operation/move_operation';
import { RemoveOperation } from '@yorkie-js-sdk/src/document/operation/remove_operation';
import { EditOperation } from '@yorkie-js-sdk/src/document/operation/edit_operation';
import { EditReverseOperation } from '@yorkie-js-sdk/src/document/operation/edit_reverse_operation';
import { StyleOperation } from '@yorkie-js-sdk/src/document/operation/style_operation';
import { TreeEditOperation } from '@yorkie-js-sdk/src/document/operation/tree_edit_operation';
import { ChangeID } from '@yorkie-js-sdk/src/document/change/change_id';
Expand Down Expand Up @@ -339,8 +340,10 @@ function toOperation(operation: Operation): PbOperation {
pbEditOperation.setFrom(toTextNodePos(editOperation.getFromPos()));
pbEditOperation.setTo(toTextNodePos(editOperation.getToPos()));
const pbCreatedAtMapByActor = pbEditOperation.getCreatedAtMapByActorMap();
for (const [key, value] of editOperation.getMaxCreatedAtMapByActor()) {
pbCreatedAtMapByActor.set(key, toTimeTicket(value)!);
if (editOperation.getMaxCreatedAtMapByActor()) {
for (const [key, value] of editOperation.getMaxCreatedAtMapByActor()!) {
pbCreatedAtMapByActor.set(key, toTimeTicket(value)!);
}
}
pbEditOperation.setContent(editOperation.getContent());
const pbAttributes = pbEditOperation.getAttributesMap();
Expand All @@ -349,6 +352,33 @@ function toOperation(operation: Operation): PbOperation {
}
pbEditOperation.setExecutedAt(toTimeTicket(editOperation.getExecutedAt()));
pbOperation.setEdit(pbEditOperation);
} else if (operation instanceof EditReverseOperation) {
const editReverseOperation = operation as EditReverseOperation;
const pbEditReverseOperation = new PbOperation.EditReverse();
pbEditReverseOperation.setParentCreatedAt(
toTimeTicket(editReverseOperation.getParentCreatedAt()),
);
pbEditReverseOperation.setFromIdx(editReverseOperation.getFromIdx());
pbEditReverseOperation.setToIdx(editReverseOperation.getToIdx());
const pbCreatedAtMapByActor =
pbEditReverseOperation.getCreatedAtMapByActorMap();
if (editReverseOperation.getMaxCreatedAtMapByActor()) {
for (const [
key,
value,
] of editReverseOperation.getMaxCreatedAtMapByActor()!) {
pbCreatedAtMapByActor.set(key, toTimeTicket(value)!);
}
}
pbEditReverseOperation.setContent(editReverseOperation.getContent());
const pbAttributes = pbEditReverseOperation.getAttributesMap();
for (const [key, value] of editReverseOperation.getAttributes()) {
pbAttributes.set(key, value);
}
pbEditReverseOperation.setExecutedAt(
toTimeTicket(editReverseOperation.getExecutedAt()),
);
pbOperation.setEditReverse(pbEditReverseOperation);
} else if (operation instanceof StyleOperation) {
const styleOperation = operation as StyleOperation;
const pbStyleOperation = new PbOperation.Style();
Expand Down Expand Up @@ -1066,15 +1096,38 @@ function fromOperations(pbOperations: Array<PbOperation>): Array<Operation> {
pbEditOperation!.getAttributesMap().forEach((value, key) => {
attributes.set(key, value);
});
operation = EditOperation.create(
fromTimeTicket(pbEditOperation!.getParentCreatedAt())!,
fromTextNodePos(pbEditOperation!.getFrom()!),
fromTextNodePos(pbEditOperation!.getTo()!),
createdAtMapByActor,
pbEditOperation!.getContent(),
operation = EditOperation.create({
parentCreatedAt: fromTimeTicket(pbEditOperation!.getParentCreatedAt())!,
fromPos: fromTextNodePos(pbEditOperation!.getFrom()!),
toPos: fromTextNodePos(pbEditOperation!.getTo()!),
content: pbEditOperation!.getContent(),
attributes,
fromTimeTicket(pbEditOperation!.getExecutedAt())!,
);
executedAt: fromTimeTicket(pbEditOperation!.getExecutedAt())!,
maxCreatedAtMapByActor: createdAtMapByActor,
});
} else if (pbOperation.hasEditReverse()) {
const pbEditReverseOperation = pbOperation.getEditReverse();
const createdAtMapByActor = new Map();
pbEditReverseOperation!
.getCreatedAtMapByActorMap()
.forEach((value, key) => {
createdAtMapByActor.set(key, fromTimeTicket(value));
});
const attributes = new Map();
pbEditReverseOperation!.getAttributesMap().forEach((value, key) => {
attributes.set(key, value);
});
operation = EditReverseOperation.create({
parentCreatedAt: fromTimeTicket(
pbEditReverseOperation!.getParentCreatedAt(),
)!,
fromIdx: pbEditReverseOperation!.getFromIdx()!,
toIdx: pbEditReverseOperation!.getToIdx()!,
content: pbEditReverseOperation!.getContent(),
attributes,
executedAt: fromTimeTicket(pbEditReverseOperation!.getExecutedAt())!,
maxCreatedAtMapByActor: createdAtMapByActor,
});
} else if (pbOperation.hasStyle()) {
const pbStyleOperation = pbOperation.getStyle();
const createdAtMapByActor = new Map();
Expand Down Expand Up @@ -1198,7 +1251,11 @@ function fromObject(pbObject: PbJSONElement.JSONObject): CRDTObject {
const rht = new ElementRHT();
for (const pbRHTNode of pbObject.getNodesList()) {
// eslint-disable-next-line
rht.set(pbRHTNode.getKey(), fromElement(pbRHTNode.getElement()!));
rht.set(
pbRHTNode.getKey(),
fromElement(pbRHTNode.getElement()!),
fromTimeTicket(pbObject.getCreatedAt())!,
);
}

const obj = new CRDTObject(fromTimeTicket(pbObject.getCreatedAt())!, rht);
Expand Down
10 changes: 10 additions & 0 deletions src/api/yorkie/v1/resources.proto
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ message Operation {
map<string, string> attributes = 4;
TimeTicket executed_at = 5;
}
message EditReverse {
TimeTicket parent_created_at = 1;
int32 from_idx = 2;
int32 to_idx = 3;
map<string, TimeTicket> created_at_map_by_actor = 4;
string content = 5;
TimeTicket executed_at = 6;
map<string, string> attributes = 7;
}

oneof body {
Set set = 1;
Expand All @@ -145,6 +154,7 @@ message Operation {
Increase increase = 8;
TreeEdit tree_edit = 9;
TreeStyle tree_style = 10;
EditReverse edit_reverse = 11;
}
}

Expand Down
54 changes: 54 additions & 0 deletions src/api/yorkie/v1/resources_pb.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ export class Operation extends jspb.Message {
hasTreeStyle(): boolean;
clearTreeStyle(): Operation;

getEditReverse(): Operation.EditReverse | undefined;
setEditReverse(value?: Operation.EditReverse): Operation;
hasEditReverse(): boolean;
clearEditReverse(): Operation;

getBodyCase(): Operation.BodyCase;

serializeBinary(): Uint8Array;
Expand All @@ -215,6 +220,7 @@ export namespace Operation {
increase?: Operation.Increase.AsObject,
treeEdit?: Operation.TreeEdit.AsObject,
treeStyle?: Operation.TreeStyle.AsObject,
editReverse?: Operation.EditReverse.AsObject,
}

export class Set extends jspb.Message {
Expand Down Expand Up @@ -627,6 +633,53 @@ export namespace Operation {
}


export class EditReverse extends jspb.Message {
getParentCreatedAt(): TimeTicket | undefined;
setParentCreatedAt(value?: TimeTicket): EditReverse;
hasParentCreatedAt(): boolean;
clearParentCreatedAt(): EditReverse;

getFromIdx(): number;
setFromIdx(value: number): EditReverse;

getToIdx(): number;
setToIdx(value: number): EditReverse;

getCreatedAtMapByActorMap(): jspb.Map<string, TimeTicket>;
clearCreatedAtMapByActorMap(): EditReverse;

getContent(): string;
setContent(value: string): EditReverse;

getExecutedAt(): TimeTicket | undefined;
setExecutedAt(value?: TimeTicket): EditReverse;
hasExecutedAt(): boolean;
clearExecutedAt(): EditReverse;

getAttributesMap(): jspb.Map<string, string>;
clearAttributesMap(): EditReverse;

serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): EditReverse.AsObject;
static toObject(includeInstance: boolean, msg: EditReverse): EditReverse.AsObject;
static serializeBinaryToWriter(message: EditReverse, writer: jspb.BinaryWriter): void;
static deserializeBinary(bytes: Uint8Array): EditReverse;
static deserializeBinaryFromReader(message: EditReverse, reader: jspb.BinaryReader): EditReverse;
}

export namespace EditReverse {
export type AsObject = {
parentCreatedAt?: TimeTicket.AsObject,
fromIdx: number,
toIdx: number,
createdAtMapByActorMap: Array<[string, TimeTicket.AsObject]>,
content: string,
executedAt?: TimeTicket.AsObject,
attributesMap: Array<[string, string]>,
}
}


export enum BodyCase {
BODY_NOT_SET = 0,
SET = 1,
Expand All @@ -639,6 +692,7 @@ export namespace Operation {
INCREASE = 8,
TREE_EDIT = 9,
TREE_STYLE = 10,
EDIT_REVERSE = 11,
}
}

Expand Down
Loading