diff --git a/public/devtool/text.css b/public/devtool/text.css index 26be992b2..1d6fe737f 100644 --- a/public/devtool/text.css +++ b/public/devtool/text.css @@ -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; } diff --git a/public/index.html b/public/index.html index 965c83a71..fb6db9641 100644 --- a/public/index.html +++ b/public/index.html @@ -17,6 +17,7 @@
+
+
+ + +
@@ -332,7 +337,9 @@

await client.activate(); // 03-1. create a document then attach it into the client. - const doc = new yorkie.Document('codemirror'); + const doc = new yorkie.Document('codemirror', { + disableGC: true, + }); doc.subscribe('presence', (event) => { if (event.type === 'presence-changed') return; displayUsers(doc.getPresences(), client.getID()); @@ -367,9 +374,27 @@

displayRemoteSelection(codemirror, doc, event.value); } }); + doc.subscribe('my-presence', (event) => { + if ( + event.type === 'presence-changed' && + event.message === 'YORKIE_HISTORY' + ) { + const [fromIdx, toIdx] = doc + .getRoot() + .content.posRangeToIndexRange(event.value.presence.selection); + + const from = codemirror.posFromIndex(fromIdx); + const to = codemirror.posFromIndex(toIdx); + codemirror.setSelection(from, to, { origin: 'yorkie' }); + } + }); 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); @@ -397,9 +422,12 @@

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'); }); @@ -422,13 +450,12 @@

// NOTE: The following conditional statement ignores cursor changes // that occur while applying remote changes to CodeMirror // and handles only movement by keyboard and mouse. - if (change.origin === undefined) { + console.log(change.origin); + if (change.origin === undefined || change.origin === 'yorkie') { return; } - 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]), @@ -455,6 +482,30 @@

} } + // 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); diff --git a/src/api/converter.ts b/src/api/converter.ts index 689c81fd9..a5d402ae8 100644 --- a/src/api/converter.ts +++ b/src/api/converter.ts @@ -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'; @@ -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(); @@ -349,6 +352,34 @@ 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()), + ); + + const pbDeletedIDs = []; + const deletedIDs = editReverseOperation.getDeletedIDs(); + for (const deletedID of deletedIDs) { + pbDeletedIDs.push(toTextNodePos(deletedID)); + } + const pbInsertedIDs = []; + const insertedIDs = editReverseOperation.getInsertedIDs(); + for (const insertedID of insertedIDs) { + pbInsertedIDs.push(toTextNodePos(insertedID)); + } + pbEditReverseOperation.setDeletedIdsList(pbDeletedIDs); + pbEditReverseOperation.setInsertedIdsList(pbInsertedIDs); + + 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(); @@ -1066,15 +1097,42 @@ function fromOperations(pbOperations: Array): Array { 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 attributes = new Map(); + pbEditReverseOperation!.getAttributesMap().forEach((value, key) => { + attributes.set(key, value); + }); + + const pbDeletedIDs = pbEditReverseOperation!.getDeletedIdsList()!; + const deletedIDs = []; + for (const pbDeletedID of pbDeletedIDs) { + deletedIDs.push(fromTextNodePos(pbDeletedID)); + } + const pbInsertedIDs = pbEditReverseOperation!.getInsertedIdsList()!; + const insertedIDs = []; + for (const pbInsertedID of pbInsertedIDs) { + insertedIDs.push(fromTextNodePos(pbInsertedID)); + } + + operation = EditReverseOperation.create({ + parentCreatedAt: fromTimeTicket( + pbEditReverseOperation!.getParentCreatedAt(), + )!, + deletedIDs, + insertedIDs, + attributes, + executedAt: fromTimeTicket(pbEditReverseOperation!.getExecutedAt())!, + }); } else if (pbOperation.hasStyle()) { const pbStyleOperation = pbOperation.getStyle(); const createdAtMapByActor = new Map(); diff --git a/src/api/yorkie/v1/resources.proto b/src/api/yorkie/v1/resources.proto index 75947b1df..8b70272fc 100644 --- a/src/api/yorkie/v1/resources.proto +++ b/src/api/yorkie/v1/resources.proto @@ -133,6 +133,13 @@ message Operation { map attributes = 4; TimeTicket executed_at = 5; } + message EditReverse { + TimeTicket parent_created_at = 1; + repeated TextNodePos deleted_ids = 2; + repeated TextNodePos inserted_ids = 3; + TimeTicket executed_at = 4; + map attributes = 5; + } oneof body { Set set = 1; @@ -145,6 +152,7 @@ message Operation { Increase increase = 8; TreeEdit tree_edit = 9; TreeStyle tree_style = 10; + EditReverse edit_reverse = 11; } } diff --git a/src/api/yorkie/v1/resources_pb.d.ts b/src/api/yorkie/v1/resources_pb.d.ts index 3f1c314c7..7237b681d 100644 --- a/src/api/yorkie/v1/resources_pb.d.ts +++ b/src/api/yorkie/v1/resources_pb.d.ts @@ -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; @@ -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 { @@ -627,6 +633,49 @@ export namespace Operation { } + export class EditReverse extends jspb.Message { + getParentCreatedAt(): TimeTicket | undefined; + setParentCreatedAt(value?: TimeTicket): EditReverse; + hasParentCreatedAt(): boolean; + clearParentCreatedAt(): EditReverse; + + getDeletedIdsList(): Array; + setDeletedIdsList(value: Array): EditReverse; + clearDeletedIdsList(): EditReverse; + addDeletedIds(value?: TextNodePos, index?: number): TextNodePos; + + getInsertedIdsList(): Array; + setInsertedIdsList(value: Array): EditReverse; + clearInsertedIdsList(): EditReverse; + addInsertedIds(value?: TextNodePos, index?: number): TextNodePos; + + getExecutedAt(): TimeTicket | undefined; + setExecutedAt(value?: TimeTicket): EditReverse; + hasExecutedAt(): boolean; + clearExecutedAt(): EditReverse; + + getAttributesMap(): jspb.Map; + 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, + deletedIdsList: Array, + insertedIdsList: Array, + executedAt?: TimeTicket.AsObject, + attributesMap: Array<[string, string]>, + } + } + + export enum BodyCase { BODY_NOT_SET = 0, SET = 1, @@ -639,6 +688,7 @@ export namespace Operation { INCREASE = 8, TREE_EDIT = 9, TREE_STYLE = 10, + EDIT_REVERSE = 11, } } diff --git a/src/api/yorkie/v1/resources_pb.js b/src/api/yorkie/v1/resources_pb.js index 841faa4fa..d65feb202 100644 --- a/src/api/yorkie/v1/resources_pb.js +++ b/src/api/yorkie/v1/resources_pb.js @@ -40,6 +40,7 @@ goog.exportSymbol('proto.yorkie.v1.Operation', null, global); goog.exportSymbol('proto.yorkie.v1.Operation.Add', null, global); goog.exportSymbol('proto.yorkie.v1.Operation.BodyCase', null, global); goog.exportSymbol('proto.yorkie.v1.Operation.Edit', null, global); +goog.exportSymbol('proto.yorkie.v1.Operation.EditReverse', null, global); goog.exportSymbol('proto.yorkie.v1.Operation.Increase', null, global); goog.exportSymbol('proto.yorkie.v1.Operation.Move', null, global); goog.exportSymbol('proto.yorkie.v1.Operation.Remove', null, global); @@ -382,6 +383,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.yorkie.v1.Operation.TreeStyle.displayName = 'proto.yorkie.v1.Operation.TreeStyle'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.yorkie.v1.Operation.EditReverse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.yorkie.v1.Operation.EditReverse.repeatedFields_, null); +}; +goog.inherits(proto.yorkie.v1.Operation.EditReverse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.yorkie.v1.Operation.EditReverse.displayName = 'proto.yorkie.v1.Operation.EditReverse'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -2075,7 +2097,7 @@ proto.yorkie.v1.ChangeID.prototype.setActorId = function(value) { * @private {!Array>} * @const */ -proto.yorkie.v1.Operation.oneofGroups_ = [[1,2,3,4,5,6,7,8,9,10]]; +proto.yorkie.v1.Operation.oneofGroups_ = [[1,2,3,4,5,6,7,8,9,10,11]]; /** * @enum {number} @@ -2091,7 +2113,8 @@ proto.yorkie.v1.Operation.BodyCase = { STYLE: 7, INCREASE: 8, TREE_EDIT: 9, - TREE_STYLE: 10 + TREE_STYLE: 10, + EDIT_REVERSE: 11 }; /** @@ -2141,7 +2164,8 @@ proto.yorkie.v1.Operation.toObject = function(includeInstance, msg) { style: (f = msg.getStyle()) && proto.yorkie.v1.Operation.Style.toObject(includeInstance, f), increase: (f = msg.getIncrease()) && proto.yorkie.v1.Operation.Increase.toObject(includeInstance, f), treeEdit: (f = msg.getTreeEdit()) && proto.yorkie.v1.Operation.TreeEdit.toObject(includeInstance, f), - treeStyle: (f = msg.getTreeStyle()) && proto.yorkie.v1.Operation.TreeStyle.toObject(includeInstance, f) + treeStyle: (f = msg.getTreeStyle()) && proto.yorkie.v1.Operation.TreeStyle.toObject(includeInstance, f), + editReverse: (f = msg.getEditReverse()) && proto.yorkie.v1.Operation.EditReverse.toObject(includeInstance, f) }; if (includeInstance) { @@ -2228,6 +2252,11 @@ proto.yorkie.v1.Operation.deserializeBinaryFromReader = function(msg, reader) { reader.readMessage(value,proto.yorkie.v1.Operation.TreeStyle.deserializeBinaryFromReader); msg.setTreeStyle(value); break; + case 11: + var value = new proto.yorkie.v1.Operation.EditReverse; + reader.readMessage(value,proto.yorkie.v1.Operation.EditReverse.deserializeBinaryFromReader); + msg.setEditReverse(value); + break; default: reader.skipField(); break; @@ -2337,6 +2366,14 @@ proto.yorkie.v1.Operation.serializeBinaryToWriter = function(message, writer) { proto.yorkie.v1.Operation.TreeStyle.serializeBinaryToWriter ); } + f = message.getEditReverse(); + if (f != null) { + writer.writeMessage( + 11, + f, + proto.yorkie.v1.Operation.EditReverse.serializeBinaryToWriter + ); + } }; @@ -5545,6 +5582,354 @@ proto.yorkie.v1.Operation.TreeStyle.prototype.hasExecutedAt = function() { }; + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.yorkie.v1.Operation.EditReverse.repeatedFields_ = [2,3]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.toObject = function(opt_includeInstance) { + return proto.yorkie.v1.Operation.EditReverse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.yorkie.v1.Operation.EditReverse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.yorkie.v1.Operation.EditReverse.toObject = function(includeInstance, msg) { + var f, obj = { + parentCreatedAt: (f = msg.getParentCreatedAt()) && proto.yorkie.v1.TimeTicket.toObject(includeInstance, f), + deletedIdsList: jspb.Message.toObjectList(msg.getDeletedIdsList(), + proto.yorkie.v1.TextNodePos.toObject, includeInstance), + insertedIdsList: jspb.Message.toObjectList(msg.getInsertedIdsList(), + proto.yorkie.v1.TextNodePos.toObject, includeInstance), + executedAt: (f = msg.getExecutedAt()) && proto.yorkie.v1.TimeTicket.toObject(includeInstance, f), + attributesMap: (f = msg.getAttributesMap()) ? f.toObject(includeInstance, undefined) : [] + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.yorkie.v1.Operation.EditReverse} + */ +proto.yorkie.v1.Operation.EditReverse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.yorkie.v1.Operation.EditReverse; + return proto.yorkie.v1.Operation.EditReverse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.yorkie.v1.Operation.EditReverse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.yorkie.v1.Operation.EditReverse} + */ +proto.yorkie.v1.Operation.EditReverse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = new proto.yorkie.v1.TimeTicket; + reader.readMessage(value,proto.yorkie.v1.TimeTicket.deserializeBinaryFromReader); + msg.setParentCreatedAt(value); + break; + case 2: + var value = new proto.yorkie.v1.TextNodePos; + reader.readMessage(value,proto.yorkie.v1.TextNodePos.deserializeBinaryFromReader); + msg.addDeletedIds(value); + break; + case 3: + var value = new proto.yorkie.v1.TextNodePos; + reader.readMessage(value,proto.yorkie.v1.TextNodePos.deserializeBinaryFromReader); + msg.addInsertedIds(value); + break; + case 4: + var value = new proto.yorkie.v1.TimeTicket; + reader.readMessage(value,proto.yorkie.v1.TimeTicket.deserializeBinaryFromReader); + msg.setExecutedAt(value); + break; + case 5: + var value = msg.getAttributesMap(); + reader.readMessage(value, function(message, reader) { + jspb.Map.deserializeBinary(message, reader, jspb.BinaryReader.prototype.readString, jspb.BinaryReader.prototype.readString, null, "", ""); + }); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.yorkie.v1.Operation.EditReverse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.yorkie.v1.Operation.EditReverse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.yorkie.v1.Operation.EditReverse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getParentCreatedAt(); + if (f != null) { + writer.writeMessage( + 1, + f, + proto.yorkie.v1.TimeTicket.serializeBinaryToWriter + ); + } + f = message.getDeletedIdsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 2, + f, + proto.yorkie.v1.TextNodePos.serializeBinaryToWriter + ); + } + f = message.getInsertedIdsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 3, + f, + proto.yorkie.v1.TextNodePos.serializeBinaryToWriter + ); + } + f = message.getExecutedAt(); + if (f != null) { + writer.writeMessage( + 4, + f, + proto.yorkie.v1.TimeTicket.serializeBinaryToWriter + ); + } + f = message.getAttributesMap(true); + if (f && f.getLength() > 0) { + f.serializeBinary(5, writer, jspb.BinaryWriter.prototype.writeString, jspb.BinaryWriter.prototype.writeString); + } +}; + + +/** + * optional TimeTicket parent_created_at = 1; + * @return {?proto.yorkie.v1.TimeTicket} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.getParentCreatedAt = function() { + return /** @type{?proto.yorkie.v1.TimeTicket} */ ( + jspb.Message.getWrapperField(this, proto.yorkie.v1.TimeTicket, 1)); +}; + + +/** + * @param {?proto.yorkie.v1.TimeTicket|undefined} value + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this +*/ +proto.yorkie.v1.Operation.EditReverse.prototype.setParentCreatedAt = function(value) { + return jspb.Message.setWrapperField(this, 1, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this + */ +proto.yorkie.v1.Operation.EditReverse.prototype.clearParentCreatedAt = function() { + return this.setParentCreatedAt(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.hasParentCreatedAt = function() { + return jspb.Message.getField(this, 1) != null; +}; + + +/** + * repeated TextNodePos deleted_ids = 2; + * @return {!Array} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.getDeletedIdsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.yorkie.v1.TextNodePos, 2)); +}; + + +/** + * @param {!Array} value + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this +*/ +proto.yorkie.v1.Operation.EditReverse.prototype.setDeletedIdsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 2, value); +}; + + +/** + * @param {!proto.yorkie.v1.TextNodePos=} opt_value + * @param {number=} opt_index + * @return {!proto.yorkie.v1.TextNodePos} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.addDeletedIds = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 2, opt_value, proto.yorkie.v1.TextNodePos, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this + */ +proto.yorkie.v1.Operation.EditReverse.prototype.clearDeletedIdsList = function() { + return this.setDeletedIdsList([]); +}; + + +/** + * repeated TextNodePos inserted_ids = 3; + * @return {!Array} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.getInsertedIdsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, proto.yorkie.v1.TextNodePos, 3)); +}; + + +/** + * @param {!Array} value + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this +*/ +proto.yorkie.v1.Operation.EditReverse.prototype.setInsertedIdsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 3, value); +}; + + +/** + * @param {!proto.yorkie.v1.TextNodePos=} opt_value + * @param {number=} opt_index + * @return {!proto.yorkie.v1.TextNodePos} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.addInsertedIds = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 3, opt_value, proto.yorkie.v1.TextNodePos, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this + */ +proto.yorkie.v1.Operation.EditReverse.prototype.clearInsertedIdsList = function() { + return this.setInsertedIdsList([]); +}; + + +/** + * optional TimeTicket executed_at = 4; + * @return {?proto.yorkie.v1.TimeTicket} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.getExecutedAt = function() { + return /** @type{?proto.yorkie.v1.TimeTicket} */ ( + jspb.Message.getWrapperField(this, proto.yorkie.v1.TimeTicket, 4)); +}; + + +/** + * @param {?proto.yorkie.v1.TimeTicket|undefined} value + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this +*/ +proto.yorkie.v1.Operation.EditReverse.prototype.setExecutedAt = function(value) { + return jspb.Message.setWrapperField(this, 4, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this + */ +proto.yorkie.v1.Operation.EditReverse.prototype.clearExecutedAt = function() { + return this.setExecutedAt(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.hasExecutedAt = function() { + return jspb.Message.getField(this, 4) != null; +}; + + +/** + * map attributes = 5; + * @param {boolean=} opt_noLazyCreate Do not create the map if + * empty, instead returning `undefined` + * @return {!jspb.Map} + */ +proto.yorkie.v1.Operation.EditReverse.prototype.getAttributesMap = function(opt_noLazyCreate) { + return /** @type {!jspb.Map} */ ( + jspb.Message.getMapField(this, 5, opt_noLazyCreate, + null)); +}; + + +/** + * Clears values from the map. The map will be non-null. + * @return {!proto.yorkie.v1.Operation.EditReverse} returns this + */ +proto.yorkie.v1.Operation.EditReverse.prototype.clearAttributesMap = function() { + this.getAttributesMap().clear(); + return this;}; + + /** * optional Set set = 1; * @return {?proto.yorkie.v1.Operation.Set} @@ -5915,6 +6300,43 @@ proto.yorkie.v1.Operation.prototype.hasTreeStyle = function() { }; +/** + * optional EditReverse edit_reverse = 11; + * @return {?proto.yorkie.v1.Operation.EditReverse} + */ +proto.yorkie.v1.Operation.prototype.getEditReverse = function() { + return /** @type{?proto.yorkie.v1.Operation.EditReverse} */ ( + jspb.Message.getWrapperField(this, proto.yorkie.v1.Operation.EditReverse, 11)); +}; + + +/** + * @param {?proto.yorkie.v1.Operation.EditReverse|undefined} value + * @return {!proto.yorkie.v1.Operation} returns this +*/ +proto.yorkie.v1.Operation.prototype.setEditReverse = function(value) { + return jspb.Message.setOneofWrapperField(this, 11, proto.yorkie.v1.Operation.oneofGroups_[0], value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.yorkie.v1.Operation} returns this + */ +proto.yorkie.v1.Operation.prototype.clearEditReverse = function() { + return this.setEditReverse(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.yorkie.v1.Operation.prototype.hasEditReverse = function() { + return jspb.Message.getField(this, 11) != null; +}; + + diff --git a/src/api/yorkie/v1/yorkie_grpc_web_pb.js b/src/api/yorkie/v1/yorkie_grpc_web_pb.js index ff8f94ad1..2eef83bd0 100644 --- a/src/api/yorkie/v1/yorkie_grpc_web_pb.js +++ b/src/api/yorkie/v1/yorkie_grpc_web_pb.js @@ -4,11 +4,7 @@ * @public */ -// Code generated by protoc-gen-grpc-web. DO NOT EDIT. -// versions: -// protoc-gen-grpc-web v1.4.2 -// protoc v3.20.3 -// source: yorkie/v1/yorkie.proto +// GENERATED CODE -- DO NOT EDIT! /* eslint-disable */ @@ -46,7 +42,7 @@ proto.yorkie.v1.YorkieServiceClient = /** * @private @const {string} The hostname */ - this.hostname_ = hostname.replace(/\/+$/, ''); + this.hostname_ = hostname; }; @@ -72,7 +68,7 @@ proto.yorkie.v1.YorkieServicePromiseClient = /** * @private @const {string} The hostname */ - this.hostname_ = hostname.replace(/\/+$/, ''); + this.hostname_ = hostname; }; diff --git a/src/document/crdt/rga_tree_split.ts b/src/document/crdt/rga_tree_split.ts index 9a94e8290..9b83235fc 100644 --- a/src/document/crdt/rga_tree_split.ts +++ b/src/document/crdt/rga_tree_split.ts @@ -533,21 +533,26 @@ export class RGATreeSplit { * @param editedAt - edited time * @param value - value * @param latestCreatedAtMapByActor - latestCreatedAtMapByActor - * @returns `[RGATreeSplitPos, Map, Array]` + * @returns `[RGATreeSplitPos, Map, Array, Array<{ nodeID: RGATreeSplitNodeID, length: number }>]` */ public edit( range: RGATreeSplitPosRange, editedAt: TimeTicket, value?: T, latestCreatedAtMapByActor?: Map, - ): [RGATreeSplitPos, Map, Array>] { + ): [ + RGATreeSplitPos, + Map, + Array>, + Array, + ] { // 01. split nodes with from and to const [toLeft, toRight] = this.findNodeWithSplit(range[1], editedAt); const [fromLeft, fromRight] = this.findNodeWithSplit(range[0], editedAt); // 02. delete between from and to const nodesToDelete = this.findBetween(fromRight, toRight); - const [changes, latestCreatedAtMap, removedNodeMapByNodeKey] = + const [changes, latestCreatedAtMap, removedNodeMapByNodeKey, deletedIDs] = this.deleteNodes(nodesToDelete, editedAt, latestCreatedAtMapByActor); const caretID = toRight ? toRight.getID() : toLeft.getID(); @@ -584,7 +589,7 @@ export class RGATreeSplit { this.removedNodeMap.set(key, removedNode); } - return [caretPos, latestCreatedAtMap, changes]; + return [caretPos, latestCreatedAtMap, changes, deletedIDs]; } /** @@ -607,7 +612,11 @@ export class RGATreeSplit { /** * `posToIndex` converts the given position to index. */ - public posToIndex(pos: RGATreeSplitPos, preferToLeft: boolean): number { + public posToIndex( + pos: RGATreeSplitPos, + preferToLeft: boolean, + includeRemoved = false, + ): number { const absoluteID = pos.getAbsoluteID(); const node = preferToLeft ? this.findFloorNodePreferToLeft(absoluteID) @@ -618,9 +627,10 @@ export class RGATreeSplit { ); } const index = this.treeByIndex.indexOf(node!); - const offset = node!.isRemoved() - ? 0 - : absoluteID.getOffset() - node!.getID().getOffset(); + const offset = + node!.isRemoved() && !includeRemoved + ? 0 + : absoluteID.getOffset() - node!.getID().getOffset(); return index + offset; } @@ -761,6 +771,101 @@ export class RGATreeSplit { return [node, node.getNext()!]; } + /** + * `FindSplitNodesAndSetRemovedAt` finds all split nodes with the given id and length, + * and set `removedAt` with the fiven editedAt to remove or restore nodes. + */ + // TODO(Hyemmie): consider returning latestedCreatedAtMapByActor + public findSplitNodesAndSetRemovedAt( + ids: Array, + editedAt: TimeTicket, + toRemove: boolean, + ): Array> { + const changes: Array> = []; + for (const id of ids) { + const splitNodeIDs = this.findAllSplitNodesWithinLength(id); + const nodes = []; + for (const nodeID of splitNodeIDs) { + const node = this.setRemovedAtWithNodeID( + nodeID, + toRemove ? editedAt : undefined, + ); + + if (node) { + let curr: SplayNode = node; + while (curr) { + this.treeByIndex.updateWeight(curr); + curr = curr.getParent()!; + } + const [fromIdx, toIdx] = [ + this.posToIndex(RGATreeSplitPos.of(nodeID, 0), false, toRemove), + this.posToIndex( + RGATreeSplitPos.of(nodeID, node.getContentLength()), + true, + toRemove, + ), + ]; + changes.push({ + actor: editedAt.getActorID()!, + from: fromIdx, + to: toRemove ? toIdx : fromIdx, + value: toRemove ? undefined : node.getValue(), + }); + } + nodes.push(node); + } + } + return changes; + } + + private findAllSplitNodesWithinLength( + pos: RGATreeSplitPos, + ): Array { + const nodes = []; + // TODO(hackerwins): This logic is similar to `pos.getAbsoluteID` except + // that it subtracts 1 from the offset. We should refactor this logic. + let node = this.findFloorNode( + new RGATreeSplitNodeID( + pos.getID().getCreatedAt(), + pos.getID().getOffset() + pos.getRelativeOffset() - 1, + ), + ); + if (!node) { + logger.fatal( + `the node of the given id should be found: ${pos + .getID() + .toTestString()}`, + ); + } else { + while (node && node.getID().getOffset() >= pos.getID().getOffset()) { + nodes.push(node.getID()); + node = node.getInsPrev(); + } + } + return nodes; + } + + private setRemovedAtWithNodeID( + id: RGATreeSplitNodeID, + editedAt?: TimeTicket, + ): RGATreeSplitNode | undefined { + const node = this.findFloorNode(id); + if (!node) { + logger.fatal( + `the node of the given id should be found: ${id.toTestString()}`, + ); + } else { + // Note(Hyemmie): It restores the node if `editedAt` is undefined. + node.remove(editedAt); + if (editedAt) { + this.removedNodeMap.set(node.getID().toIDString(), node); + } else { + this.removedNodeMap.delete(node.getID().toIDString()); + } + } + return node; + } + private findFloorNodePreferToLeft( id: RGATreeSplitNodeID, ): RGATreeSplitNode { @@ -850,9 +955,10 @@ export class RGATreeSplit { Array>, Map, Map>, + Array, ] { if (!candidates.length) { - return [[], new Map(), new Map()]; + return [[], new Map(), new Map(), []]; } // There are 2 types of nodes in `candidates`: should delete, should not delete. @@ -866,6 +972,7 @@ export class RGATreeSplit { const createdAtMapByActor = new Map(); const removedNodeMap = new Map(); + const deletedIDs = []; // First we need to collect indexes for change. const changes = this.makeChanges(nodesToKeep, editedAt); for (const node of nodesToDelete) { @@ -878,12 +985,16 @@ export class RGATreeSplit { createdAtMapByActor.set(actorID, node.getID().getCreatedAt()); } removedNodeMap.set(node.getID().toIDString(), node); + if (!node.isRemoved()) + deletedIDs.push( + RGATreeSplitPos.of(node.getID(), node.getValue().length), + ); node.remove(editedAt); } // Finally remove index nodes of tombstones. this.deleteIndexNodes(nodesToKeep); - return [changes, createdAtMapByActor, removedNodeMap]; + return [changes, createdAtMapByActor, removedNodeMap, deletedIDs]; } private filterNodes( diff --git a/src/document/crdt/text.ts b/src/document/crdt/text.ts index b998aef1b..fc741da8e 100644 --- a/src/document/crdt/text.ts +++ b/src/document/crdt/text.ts @@ -27,6 +27,7 @@ import { RGATreeSplitNode, RGATreeSplitPosRange, ValueChange, + RGATreeSplitPos, } from '@yorkie-js-sdk/src/document/crdt/rga_tree_split'; import { escapeString } from '@yorkie-js-sdk/src/document/json/strings'; import { parseObjectValues } from '@yorkie-js-sdk/src/util/object'; @@ -192,7 +193,15 @@ export class CRDTText extends CRDTGCElement { editedAt: TimeTicket, attributes?: Record, latestCreatedAtMapByActor?: Map, - ): [Map, Array>, RGATreeSplitPosRange] { + ): [ + Map, + Array>, + RGATreeSplitPosRange, + { + deletedIDs: Array; + insertedIDs: Array; + }, + ] { const crdtTextValue = content ? CRDTTextValue.create(content) : undefined; if (crdtTextValue && attributes) { for (const [k, v] of Object.entries(attributes)) { @@ -200,12 +209,71 @@ export class CRDTText extends CRDTGCElement { } } - const [caretPos, latestCreatedAtMap, valueChanges] = this.rgaTreeSplit.edit( - range, - editedAt, - crdtTextValue, - latestCreatedAtMapByActor, - ); + const [caretPos, latestCreatedAtMap, valueChanges, deletedIDs] = + this.rgaTreeSplit.edit( + range, + editedAt, + crdtTextValue, + latestCreatedAtMapByActor, + ); + const reverseInfo: { + deletedIDs: Array; + insertedIDs: Array; + } = { + deletedIDs, + insertedIDs: content + ? [RGATreeSplitPos.of(caretPos.getID(), content.length)] + : [], + }; + + const changes: Array> = valueChanges.map((change) => ({ + ...change, + value: change.value + ? { + attributes: parseObjectValues(change.value.getAttributes()), + content: change.value.getContent(), + } + : { + attributes: undefined, + content: '', + }, + type: TextChangeType.Content, + })); + + return [latestCreatedAtMap, changes, [caretPos, caretPos], reverseInfo]; + } + + /** + * `reverseEdit` reverses the effect of previous edit. + * It restores the given `deletedIDs` and removes the given `insertedIDs`. + * + * @internal + */ + public reverseEdit( + deletedIDs: Array, + insertedIDs: Array, + editedAt: TimeTicket, + ): Array> { + let valueChanges: Array> = []; + if (deletedIDs.length > 0) { + const restoringDeletionChange = + this.rgaTreeSplit.findSplitNodesAndSetRemovedAt( + deletedIDs, + editedAt, + false, + ); + valueChanges = valueChanges.concat(restoringDeletionChange); + } + + if (insertedIDs.length > 0) { + const removingInsertionChange = + this.rgaTreeSplit.findSplitNodesAndSetRemovedAt( + insertedIDs, + editedAt, + true, + ); + valueChanges = valueChanges.concat(removingInsertionChange); + } const changes: Array> = valueChanges.map((change) => ({ ...change, @@ -221,7 +289,7 @@ export class CRDTText extends CRDTGCElement { type: TextChangeType.Content, })); - return [latestCreatedAtMap, changes, [caretPos, caretPos]]; + return changes; } /** diff --git a/src/document/document.ts b/src/document/document.ts index 601b24655..89657baa9 100644 --- a/src/document/document.ts +++ b/src/document/document.ts @@ -246,6 +246,7 @@ export interface PresenceChangedEvent

extends BaseDocEvent { type: DocEventType.PresenceChanged; value: { clientID: ActorID; presence: P }; + message: string; } /** @@ -558,6 +559,7 @@ export class Document { clientID: actorID, presence: this.getPresence(actorID)!, }, + message: change.getMessage() || '', }); } if (logger.isEnabled(LogLevel.Trivial)) { @@ -949,6 +951,8 @@ export class Document { * @internal */ public garbageCollect(ticket: TimeTicket): number { + // TODO(Hyemmie): To support undo&redo in text.edit, garbage collection + // should be disabled. if (this.opts.disableGC) { return 0; } @@ -1049,15 +1053,24 @@ export class Document { // NOTE(chacha912): When the user exists in onlineClients, but // their presence was initially absent, we can consider that we have // received their initial presence, so trigger the 'watched' event. - presenceEvent = { - type: this.presences.has(actorID) - ? DocEventType.PresenceChanged - : DocEventType.Watched, - value: { - clientID: actorID, - presence: presenceChange.presence, - }, - }; + if (this.presences.has(actorID)) { + presenceEvent = { + type: DocEventType.PresenceChanged, + value: { + clientID: actorID, + presence: presenceChange.presence, + }, + message: change.getMessage() || '', + }; + } else { + presenceEvent = { + type: DocEventType.Watched, + value: { + clientID: actorID, + presence: presenceChange.presence, + }, + }; + } break; case PresenceChangeType.Clear: // NOTE(chacha912): When the user exists in onlineClients, but @@ -1249,6 +1262,7 @@ export class Document { this.changeID.next(), this.clone!.root, this.clone!.presences.get(this.changeID.getActorID()!) || ({} as P), + 'YORKIE_HISTORY', ); // apply undo operation in the context to generate a change @@ -1309,6 +1323,7 @@ export class Document { clientID: actorID, presence: this.getPresence(actorID)!, }, + message: change.getMessage() || '', }); } } @@ -1332,6 +1347,7 @@ export class Document { this.changeID.next(), this.clone!.root, this.clone!.presences.get(this.changeID.getActorID()!) || ({} as P), + 'YORKIE_HISTORY', ); // apply redo operation in the context to generate a change @@ -1392,6 +1408,7 @@ export class Document { clientID: actorID, presence: this.getPresence(actorID)!, }, + message: change.getMessage() || '', }); } } diff --git a/src/document/json/text.ts b/src/document/json/text.ts index da653ad69..be15eb8a8 100644 --- a/src/document/json/text.ts +++ b/src/document/json/text.ts @@ -108,15 +108,15 @@ export class Text { ); this.context.push( - new EditOperation( - this.text.getCreatedAt(), - range[0], - range[1], - maxCreatedAtMapByActor, + new EditOperation({ + parentCreatedAt: this.text.getCreatedAt(), + fromPos: range[0], + toPos: range[1], content, - attrs ? new Map(Object.entries(attrs)) : new Map(), - ticket, - ), + attributes: attrs ? new Map(Object.entries(attrs)) : new Map(), + executedAt: ticket, + maxCreatedAtMapByActor, + }), ); if (!range[0].equals(range[1])) { diff --git a/src/document/operation/edit_operation.ts b/src/document/operation/edit_operation.ts index 05ad5c661..ac32541c0 100644 --- a/src/document/operation/edit_operation.ts +++ b/src/document/operation/edit_operation.ts @@ -25,6 +25,7 @@ import { ExecutionResult, } from '@yorkie-js-sdk/src/document/operation/operation'; import { Indexable } from '../document'; +import { EditReverseOperation } from './edit_reverse_operation'; /** * `EditOperation` is an operation representing editing Text. Most of the same as @@ -33,48 +34,64 @@ import { Indexable } from '../document'; export class EditOperation extends Operation { private fromPos: RGATreeSplitPos; private toPos: RGATreeSplitPos; - private maxCreatedAtMapByActor: Map; private content: string; private attributes: Map; + private maxCreatedAtMapByActor?: Map; - constructor( - parentCreatedAt: TimeTicket, - fromPos: RGATreeSplitPos, - toPos: RGATreeSplitPos, - maxCreatedAtMapByActor: Map, - content: string, - attributes: Map, - executedAt: TimeTicket, - ) { + constructor({ + parentCreatedAt, + fromPos, + toPos, + content, + attributes, + executedAt, + maxCreatedAtMapByActor, + }: { + parentCreatedAt: TimeTicket; + fromPos: RGATreeSplitPos; + toPos: RGATreeSplitPos; + content: string; + attributes: Map; + executedAt?: TimeTicket; + maxCreatedAtMapByActor?: Map; + }) { super(parentCreatedAt, executedAt); this.fromPos = fromPos; this.toPos = toPos; - this.maxCreatedAtMapByActor = maxCreatedAtMapByActor; this.content = content; this.attributes = attributes; + this.maxCreatedAtMapByActor = maxCreatedAtMapByActor; } /** * `create` creates a new instance of EditOperation. */ - public static create( - parentCreatedAt: TimeTicket, - fromPos: RGATreeSplitPos, - toPos: RGATreeSplitPos, - maxCreatedAtMapByActor: Map, - content: string, - attributes: Map, - executedAt: TimeTicket, - ): EditOperation { - return new EditOperation( + public static create({ + parentCreatedAt, + fromPos, + toPos, + content, + attributes, + executedAt, + maxCreatedAtMapByActor, + }: { + parentCreatedAt: TimeTicket; + fromPos: RGATreeSplitPos; + toPos: RGATreeSplitPos; + content: string; + attributes: Map; + executedAt?: TimeTicket; + maxCreatedAtMapByActor?: Map; + }): EditOperation { + return new EditOperation({ parentCreatedAt, fromPos, toPos, - maxCreatedAtMapByActor, content, attributes, executedAt, - ); + maxCreatedAtMapByActor, + }); } /** @@ -90,17 +107,21 @@ export class EditOperation extends Operation { } const text = parentObject as CRDTText; - const [, changes] = text.edit( + // TODO(chacha912): check where we can set maxCreatedAtMapByActor of edit operation(undo) + // based on the result from text.edit. + const [, changes, , reverseInfo] = text.edit( [this.fromPos, this.toPos], this.content, this.getExecutedAt(), Object.fromEntries(this.attributes), this.maxCreatedAtMapByActor, ); + const reverseOp = this.getReverseOperation(text, reverseInfo); if (!this.fromPos.equals(this.toPos)) { root.registerElementHasRemovedNodes(text); } + return { opInfos: changes.map(({ from, to, value }) => { return { @@ -109,11 +130,32 @@ export class EditOperation extends Operation { to, value, path: root.createPath(this.getParentCreatedAt()), - } as OperationInfo; - }), + }; + }) as Array, + reverseOp, }; } + /** + * `getReverseOperation` calculates this operation's reverse operation on the given `CRDTText`. + */ + public getReverseOperation( + text: CRDTText, + reverseInfo: { + deletedIDs: Array; + insertedIDs: Array; + }, + ): Operation { + // TODO(chacha912): let's assume this in plain text. + // we also need to consider rich text content. + return EditReverseOperation.create({ + parentCreatedAt: text.getCreatedAt(), + deletedIDs: reverseInfo.deletedIDs, + insertedIDs: reverseInfo.insertedIDs, + attributes: new Map(), + }); + } + /** * `getEffectedCreatedAt` returns the creation time of the effected element. */ @@ -164,7 +206,7 @@ export class EditOperation extends Operation { * `getMaxCreatedAtMapByActor` returns the map that stores the latest creation time * by actor for the nodes included in the editing range. */ - public getMaxCreatedAtMapByActor(): Map { + public getMaxCreatedAtMapByActor(): Map | undefined { return this.maxCreatedAtMapByActor; } } diff --git a/src/document/operation/edit_reverse_operation.ts b/src/document/operation/edit_reverse_operation.ts new file mode 100644 index 000000000..4343dd127 --- /dev/null +++ b/src/document/operation/edit_reverse_operation.ts @@ -0,0 +1,173 @@ +/* + * Copyright 2020 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { logger } from '@yorkie-js-sdk/src/util/logger'; +import { TimeTicket } from '@yorkie-js-sdk/src/document/time/ticket'; +import { CRDTRoot } from '@yorkie-js-sdk/src/document/crdt/root'; +import { CRDTText } from '@yorkie-js-sdk/src/document/crdt/text'; +import { + ExecutionResult, + Operation, + OperationInfo, +} from '@yorkie-js-sdk/src/document/operation/operation'; +import { Indexable } from '../document'; +import { RGATreeSplitPos } from '../crdt/rga_tree_split'; + +/** + * `EditReverseOperation` is a reverse operation of Edit operation. + */ +export class EditReverseOperation extends Operation { + // TODO(Hyemmie): need to add more fields to support + // the reverse operation of rich text edit. + private deletedIDs: Array; + private insertedIDs: Array; + private attributes?: Map; + + constructor({ + parentCreatedAt, + deletedIDs, + insertedIDs, + attributes, + executedAt, + }: { + parentCreatedAt: TimeTicket; + deletedIDs: Array; + insertedIDs: Array; + attributes?: Map; + executedAt?: TimeTicket; + }) { + super(parentCreatedAt, executedAt); + this.deletedIDs = deletedIDs; + this.insertedIDs = insertedIDs; + this.attributes = attributes; + } + + /** + * `create` creates a new instance of EditReverseOperation. + */ + public static create({ + parentCreatedAt, + deletedIDs, + insertedIDs, + attributes, + executedAt, + }: { + parentCreatedAt: TimeTicket; + deletedIDs: Array; + insertedIDs: Array; + attributes?: Map; + executedAt?: TimeTicket; + }): EditReverseOperation { + return new EditReverseOperation({ + parentCreatedAt, + deletedIDs, + insertedIDs, + attributes, + executedAt, + }); + } + + /** + * `execute` executes this operation on the given `CRDTRoot`. + */ + public execute(root: CRDTRoot): ExecutionResult { + const parentObject = root.findByCreatedAt(this.getParentCreatedAt()); + if (!parentObject) { + logger.fatal(`fail to find ${this.getParentCreatedAt()}`); + } + if (!(parentObject instanceof CRDTText)) { + logger.fatal(`fail to execute, only Text can execute edit`); + } + + const text = parentObject as CRDTText; + const reverseOp = this.getReverseOperation(); + + const changes = text.reverseEdit( + this.deletedIDs, + this.insertedIDs, + this.getExecutedAt(), + ); + + return { + opInfos: changes.map(({ from, to, value }) => { + return { + type: 'edit', + from, + to, + value, + path: root.createPath(this.getParentCreatedAt()), + }; + }) as Array, + reverseOp, + }; + } + + /** + * `getReverseOperation` calculates this operation's reverse operation on the given `CRDTText`. + */ + public getReverseOperation(): Operation { + return EditReverseOperation.create({ + parentCreatedAt: this.getParentCreatedAt(), + deletedIDs: this.insertedIDs, + insertedIDs: this.deletedIDs, + attributes: this.attributes, + }); + } + + /** + * `getEffectedCreatedAt` returns the creation time of the effected element. + */ + public getEffectedCreatedAt(): TimeTicket { + return this.getParentCreatedAt(); + } + + /** + * `getDeletedIDs` returns the deletedIDs of this operation. + */ + public getDeletedIDs(): Array { + return this.deletedIDs; + } + + /** + * `getInsertedIDs` returns the insertedIDs of this operation. + */ + public getInsertedIDs(): Array { + return this.insertedIDs; + } + + /** + * `toTestString` returns a string containing the meta data. + */ + public toTestString(): string { + const parent = this.getParentCreatedAt().toTestString(); + let deletedIDs = ''; + for (const id of this.getDeletedIDs()) { + deletedIDs = deletedIDs.concat(`${id.toTestString()}, `); + } + let insertedIDs = ''; + for (const id of this.getInsertedIDs()) { + insertedIDs = insertedIDs.concat(`${id.toTestString()}, `); + } + return `${parent}.EDIT-REVERSE(deletedIDs:[${deletedIDs}], insertedIds:[${insertedIDs}])`; + } + + /** + * `getAttributes` returns the attributes of this Edit. + */ + public getAttributes(): Map { + return this.attributes || new Map(); + } +} diff --git a/src/document/presence/presence.ts b/src/document/presence/presence.ts index e1c8a5d85..468de2b0a 100644 --- a/src/document/presence/presence.ts +++ b/src/document/presence/presence.ts @@ -30,8 +30,8 @@ export enum PresenceChangeType { * `PresenceChange` represents the change of presence. */ export type PresenceChange

= - | { type: PresenceChangeType.Put; presence: P; } - | { type: PresenceChangeType.Clear; }; + | { type: PresenceChangeType.Put; presence: P } + | { type: PresenceChangeType.Clear }; /** * `Presence` represents a proxy for the Presence to be manipulated from the outside. diff --git a/test/integration/counter_test.ts b/test/integration/counter_test.ts index 8e80070a1..514f214d9 100644 --- a/test/integration/counter_test.ts +++ b/test/integration/counter_test.ts @@ -132,146 +132,151 @@ describe('Counter', function () { assert.equal(`{"age":-9223372036854775808}`, doc.toSortedJSON()); }); - it('can get proper reverse operations', function () { - const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); - const doc = new Document<{ cnt: Counter; longCnt: Counter }>(docKey); + describe('Undo/Redo', function () { + it('can get proper reverse operations', function () { + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc = new Document<{ cnt: Counter; longCnt: Counter }>(docKey); - doc.update((root) => { - root.cnt = new Counter(CounterType.IntegerCnt, 0); - root.longCnt = new Counter(CounterType.LongCnt, Long.fromString('0')); - }); - assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`); + doc.update((root) => { + root.cnt = new Counter(CounterType.IntegerCnt, 0); + root.longCnt = new Counter(CounterType.LongCnt, Long.fromString('0')); + }); + assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`); - doc.update((root) => { - root.cnt.increase(1.5); - root.longCnt.increase(Long.fromString('9223372036854775807')); // 2^63-1 + doc.update((root) => { + root.cnt.increase(1.5); + root.longCnt.increase(Long.fromString('9223372036854775807')); // 2^63-1 + }); + assert.equal( + doc.toSortedJSON(), + `{"cnt":1,"longCnt":9223372036854775807}`, + ); + assert.equal( + JSON.stringify(doc.getUndoStackForTest()), + `[["1:00:2.INCREASE.-9223372036854775807","1:00:1.INCREASE.-1.5"]]`, + ); + + doc.history.undo(); + assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`); + assert.equal( + JSON.stringify(doc.getRedoStackForTest()), + `[["1:00:1.INCREASE.1.5","1:00:2.INCREASE.9223372036854775807"]]`, + ); }); - assert.equal(doc.toSortedJSON(), `{"cnt":1,"longCnt":9223372036854775807}`); - assert.equal( - JSON.stringify(doc.getUndoStackForTest()), - `[["1:00:2.INCREASE.-9223372036854775807","1:00:1.INCREASE.-1.5"]]`, - ); - - doc.history.undo(); - assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`); - assert.equal( - JSON.stringify(doc.getRedoStackForTest()), - `[["1:00:1.INCREASE.1.5","1:00:2.INCREASE.9223372036854775807"]]`, - ); - }); - it('Can undo/redo for increase operation', async function () { - type TestDoc = { counter: Counter }; - const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); - const doc = new Document(docKey); - doc.update((root) => { - root.counter = new Counter(CounterType.IntegerCnt, 100); - }, 'init counter'); - assert.equal(doc.toSortedJSON(), '{"counter":100}'); + it('Can undo/redo for increase operation', async function () { + type TestDoc = { counter: Counter }; + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc = new Document(docKey); + doc.update((root) => { + root.counter = new Counter(CounterType.IntegerCnt, 100); + }, 'init counter'); + assert.equal(doc.toSortedJSON(), '{"counter":100}'); - doc.update((root) => { - root.counter.increase(1); - }, 'increase 1'); - assert.equal(doc.toSortedJSON(), '{"counter":101}'); + doc.update((root) => { + root.counter.increase(1); + }, 'increase 1'); + assert.equal(doc.toSortedJSON(), '{"counter":101}'); - doc.history.undo(); - assert.equal(doc.toSortedJSON(), '{"counter":100}'); + doc.history.undo(); + assert.equal(doc.toSortedJSON(), '{"counter":100}'); - doc.history.redo(); - assert.equal(doc.toSortedJSON(), '{"counter":101}'); + doc.history.redo(); + assert.equal(doc.toSortedJSON(), '{"counter":101}'); - doc.history.undo(); - assert.equal(doc.toSortedJSON(), '{"counter":100}'); - }); + doc.history.undo(); + assert.equal(doc.toSortedJSON(), '{"counter":100}'); + }); - it('should handle undo/redo for long type and overflow', function () { - const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); - const doc = new Document<{ cnt: Counter; longCnt: Counter }>(docKey); - const states: Array = []; + it('should handle undo/redo for long type and overflow', function () { + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc = new Document<{ cnt: Counter; longCnt: Counter }>(docKey); + const states: Array = []; - doc.update((root) => { - root.cnt = new Counter(CounterType.IntegerCnt, 0); - root.longCnt = new Counter(CounterType.LongCnt, Long.fromString('0')); - }); - assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`); - states.push(doc.toSortedJSON()); + doc.update((root) => { + root.cnt = new Counter(CounterType.IntegerCnt, 0); + root.longCnt = new Counter(CounterType.LongCnt, Long.fromString('0')); + }); + assert.equal(doc.toSortedJSON(), `{"cnt":0,"longCnt":0}`); + states.push(doc.toSortedJSON()); - doc.update((root) => { - root.cnt.increase(2147483647); // 2^31-1 - root.longCnt.increase(Long.fromString('9223372036854775807')); // 2^63-1 - }); - assert.equal( - doc.toSortedJSON(), - `{"cnt":2147483647,"longCnt":9223372036854775807}`, - ); - states.push(doc.toSortedJSON()); + doc.update((root) => { + root.cnt.increase(2147483647); // 2^31-1 + root.longCnt.increase(Long.fromString('9223372036854775807')); // 2^63-1 + }); + assert.equal( + doc.toSortedJSON(), + `{"cnt":2147483647,"longCnt":9223372036854775807}`, + ); + states.push(doc.toSortedJSON()); - doc.update((root) => { - root.cnt.increase(1); // overflow - root.longCnt.increase(Long.fromString('1')); // overflow - }); - assert.equal( - doc.toSortedJSON(), - `{"cnt":-2147483648,"longCnt":-9223372036854775808}`, - ); - states.push(doc.toSortedJSON()); + doc.update((root) => { + root.cnt.increase(1); // overflow + root.longCnt.increase(Long.fromString('1')); // overflow + }); + assert.equal( + doc.toSortedJSON(), + `{"cnt":-2147483648,"longCnt":-9223372036854775808}`, + ); + states.push(doc.toSortedJSON()); - assertUndoRedo(doc, states); - }); + assertUndoRedo(doc, states); + }); - it('Can undo/redo for concurrent users', async function () { - type TestDoc = { counter: Counter }; - const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); - const doc1 = new yorkie.Document(docKey); - const doc2 = new yorkie.Document(docKey); - - const client1 = new yorkie.Client(testRPCAddr); - const client2 = new yorkie.Client(testRPCAddr); - await client1.activate(); - await client2.activate(); - - await client1.attach(doc1, { isRealtimeSync: false }); - doc1.update((root) => { - root.counter = new Counter(yorkie.IntType, 100); - }, 'init counter'); - await client1.sync(); - assert.equal(doc1.toSortedJSON(), '{"counter":100}'); - - await client2.attach(doc2, { isRealtimeSync: false }); - assert.equal(doc2.toSortedJSON(), '{"counter":100}'); - - // client1 increases 1 and client2 increases 2 - doc1.update((root) => { - root.counter.increase(1); - }, 'increase 1'); - doc2.update((root) => { - root.counter.increase(2); - }, 'increase 2'); - await client1.sync(); - await client2.sync(); - await client1.sync(); - assert.equal(doc1.toSortedJSON(), '{"counter":103}'); - assert.equal(doc2.toSortedJSON(), '{"counter":103}'); - - // client1 undoes one's latest increase operation - doc1.history.undo(); - await client1.sync(); - await client2.sync(); - assert.equal(doc1.toSortedJSON(), '{"counter":102}'); - assert.equal(doc2.toSortedJSON(), '{"counter":102}'); - - // only client1 can redo undone operation - assert.equal(doc1.history.canRedo(), true); - assert.equal(doc2.history.canRedo(), false); - - // client1 redoes one's latest undone operation - doc1.history.redo(); - await client1.sync(); - await client2.sync(); - assert.equal(doc1.toSortedJSON(), '{"counter":103}'); - assert.equal(doc2.toSortedJSON(), '{"counter":103}'); - - await client1.deactivate(); - await client2.deactivate(); + it('Can undo/redo for concurrent users', async function () { + type TestDoc = { counter: Counter }; + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.counter = new Counter(yorkie.IntType, 100); + }, 'init counter'); + await client1.sync(); + assert.equal(doc1.toSortedJSON(), '{"counter":100}'); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal(doc2.toSortedJSON(), '{"counter":100}'); + + // client1 increases 1 and client2 increases 2 + doc1.update((root) => { + root.counter.increase(1); + }, 'increase 1'); + doc2.update((root) => { + root.counter.increase(2); + }, 'increase 2'); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal(doc1.toSortedJSON(), '{"counter":103}'); + assert.equal(doc2.toSortedJSON(), '{"counter":103}'); + + // client1 undoes one's latest increase operation + doc1.history.undo(); + await client1.sync(); + await client2.sync(); + assert.equal(doc1.toSortedJSON(), '{"counter":102}'); + assert.equal(doc2.toSortedJSON(), '{"counter":102}'); + + // only client1 can redo undone operation + assert.equal(doc1.history.canRedo(), true); + assert.equal(doc2.history.canRedo(), false); + + // client1 redoes one's latest undone operation + doc1.history.redo(); + await client1.sync(); + await client2.sync(); + assert.equal(doc1.toSortedJSON(), '{"counter":103}'); + assert.equal(doc2.toSortedJSON(), '{"counter":103}'); + + await client1.deactivate(); + await client2.deactivate(); + }); }); }); diff --git a/test/integration/integration_helper.ts b/test/integration/integration_helper.ts index df8aeb3b6..aeba1a61d 100644 --- a/test/integration/integration_helper.ts +++ b/test/integration/integration_helper.ts @@ -48,27 +48,20 @@ export async function withTwoClientsAndDocuments( export function assertUndoRedo( doc: Document, states: Array, + compareFn: (doc: Document) => any = (doc) => doc.toSortedJSON(), ) { for (let i = 0; i < states.length - 1; i++) { doc.history.undo(); - assert.equal( - states[states.length - 2 - i], - doc.toSortedJSON(), - `undo 1-${i}`, - ); + assert.equal(states[states.length - 2 - i], compareFn(doc), `undo 1-${i}`); } for (let i = 0; i < states.length - 1; i++) { doc.history.redo(); - assert.equal(states[i + 1], doc.toSortedJSON(), `redo${i}`); + assert.equal(states[i + 1], compareFn(doc), `redo${i}`); } for (let i = 0; i < states.length - 1; i++) { doc.history.undo(); - assert.equal( - states[states.length - 2 - i], - doc.toSortedJSON(), - `undo 2-${i}`, - ); + assert.equal(states[states.length - 2 - i], compareFn(doc), `undo 2-${i}`); } } diff --git a/test/integration/presence_test.ts b/test/integration/presence_test.ts index b71e03441..16851c630 100644 --- a/test/integration/presence_test.ts +++ b/test/integration/presence_test.ts @@ -128,24 +128,28 @@ describe('Presence', function () { value: { clientID: c2ID, presence: { name: 'b' } }, }); - doc1.update((root, p) => p.set({ name: 'A' })); - doc2.update((root, p) => p.set({ name: 'B' })); + doc1.update((root, p) => p.set({ name: 'A' }), 'update A'); + doc2.update((root, p) => p.set({ name: 'B' }), 'update B'); await eventCollectorP1.waitAndVerifyNthEvent(2, { type: DocEventType.PresenceChanged, value: { clientID: c1ID, presence: { name: 'A' } }, + message: 'update A', }); await eventCollectorP1.waitAndVerifyNthEvent(3, { type: DocEventType.PresenceChanged, value: { clientID: c2ID, presence: { name: 'B' } }, + message: 'update B', }); await eventCollectorP2.waitAndVerifyNthEvent(1, { type: DocEventType.PresenceChanged, value: { clientID: c2ID, presence: { name: 'B' } }, + message: 'update B', }); await eventCollectorP2.waitAndVerifyNthEvent(2, { type: DocEventType.PresenceChanged, value: { clientID: c1ID, presence: { name: 'A' } }, + message: 'update A', }); assert.deepEqual( deepSort(doc2.getPresences()), @@ -346,10 +350,12 @@ describe(`Document.Subscribe('presence')`, function () { await eventCollectorP1.waitAndVerifyNthEvent(2, { type: DocEventType.PresenceChanged, value: { clientID: c1ID, presence: doc1.getMyPresence() }, + message: '', }); await eventCollectorP2.waitAndVerifyNthEvent(1, { type: DocEventType.PresenceChanged, value: { clientID: c1ID, presence: doc1.getMyPresence() }, + message: '', }); await c1.deactivate(); @@ -460,6 +466,7 @@ describe(`Document.Subscribe('presence')`, function () { clientID: c2ID, presence: { cursor: { x: 0, y: 0 }, name: 'b2' }, }, + message: '', }); // 03-1. c2 pauses the document, c1 receives an unwatched event from c2. @@ -501,6 +508,7 @@ describe(`Document.Subscribe('presence')`, function () { clientID: c3ID, presence: { cursor: { x: 0, y: 0 }, name: 'c3' }, }, + message: '', }); // 05-1. c3 pauses the document, c1 receives an unwatched event from c3. diff --git a/test/integration/text_test.ts b/test/integration/text_test.ts index eee70b8d2..6b036e761 100644 --- a/test/integration/text_test.ts +++ b/test/integration/text_test.ts @@ -1,7 +1,12 @@ import { assert } from 'chai'; import { TextView } from '@yorkie-js-sdk/test/helper/helper'; -import { withTwoClientsAndDocuments } from '@yorkie-js-sdk/test/integration/integration_helper'; -import { Document, Text } from '@yorkie-js-sdk/src/yorkie'; +import { + withTwoClientsAndDocuments, + assertUndoRedo, + toDocKey, + testRPCAddr, +} from '@yorkie-js-sdk/test/integration/integration_helper'; +import yorkie, { Document, Text } from '@yorkie-js-sdk/src/yorkie'; describe('Text', function () { it('should handle edit operations', function () { @@ -92,6 +97,7 @@ describe('Text', function () { const doc = new Document<{ text: Text; }>('test-doc'); + const states: Array = []; const view = new TextView(); doc.update((root) => (root.text = new Text())); doc.subscribe('$.text', (event) => { @@ -102,21 +108,27 @@ describe('Text', function () { }); const commands = [ - { from: 0, to: 0, content: 'ABC' }, - { from: 3, to: 3, content: 'DEF' }, - { from: 2, to: 4, content: '1' }, - { from: 1, to: 4, content: '2' }, + { from: 0, to: 0, content: 'ABC' }, // ABC + { from: 3, to: 3, content: 'DEF' }, // ABCDEF + { from: 2, to: 4, content: '1' }, // AB1EF + { from: 1, to: 4, content: '2' }, // A2F ]; for (const cmd of commands) { doc.update((root) => root.text.edit(cmd.from, cmd.to, cmd.content)); + states.push(doc.getValueByPath('$.text')!.toString()); assert.equal(view.toString(), doc.getRoot().text.toString()); } + assertUndoRedo(doc, states, (doc) => + doc.getValueByPath('$.text')!.toString(), + ); }); it('should handle deletion of the last nodes', function () { const doc = new Document<{ text: Text }>('test-doc'); + const states: Array = []; const view = new TextView(); + doc.update((root) => (root.text = new Text())); doc.subscribe('$.text', (event) => { if (event.type === 'local-change') { @@ -142,12 +154,17 @@ describe('Text', function () { for (const cmd of commands) { doc.update((root) => root.text.edit(cmd.from, cmd.to, cmd.content!)); + states.push(doc.getValueByPath('$.text')!.toString()); assert.equal(view.toString(), doc.getRoot().text.toString()); } + assertUndoRedo(doc, states, (doc) => + doc.getValueByPath('$.text')!.toString(), + ); }); it('should handle deletion with boundary nodes already removed', function () { const doc = new Document<{ text: Text }>('test-doc'); + const states: Array = []; const view = new TextView(); doc.update((root) => (root.text = new Text())); doc.subscribe('$.text', (event) => { @@ -171,6 +188,7 @@ describe('Text', function () { for (const cmd of commands) { doc.update((root) => root.text.edit(cmd.from, cmd.to, cmd.content!)); + states.push(doc.getValueByPath('$.text')!.toString()); assert.equal(view.toString(), doc.getRoot().text.toString()); } }); @@ -405,6 +423,520 @@ describe('Text', function () { // assert.isOk(d2.getRoot().k1.checkWeight()); }, this.test!.title); }); + + // TODO(Hyemmie): The text.edit test is not currently available + // in the `yorkieteam/yorkie` docker image because it requires a protocol change. + // To test this case, you need to stop the docker yorkie container + // and run the yorkie server with the code from the `feat/text-edit-reverse` + // branch of the yorkie repository. + describe('Undo/Redo', function () { + it('Can undo/redo for text edit operation', async function () { + type TestDoc = { text: Text }; + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc = new Document(docKey); + doc.update((root) => { + root.text = new Text(); + root.text.edit(0, 0, 'ABCD'); + }, 'init text'); + assert.equal(doc.toSortedJSON(), '{"text":[{"val":"ABCD"}]}'); + + doc.update((root) => { + root.text.edit(1, 3, '12'); + }, `edit 'ABCD' to 'A12D'`); + assert.equal( + doc.toSortedJSON(), + '{"text":[{"val":"A"},{"val":"12"},{"val":"D"}]}', + ); + + doc.history.undo(); + assert.equal( + doc.toSortedJSON(), + '{"text":[{"val":"A"},{"val":"BC"},{"val":"D"}]}', + ); + + doc.history.redo(); + assert.equal( + doc.toSortedJSON(), + '{"text":[{"val":"A"},{"val":"12"},{"val":"D"}]}', + ); + + doc.history.undo(); + assert.equal( + doc.toSortedJSON(), + '{"text":[{"val":"A"},{"val":"BC"},{"val":"D"}]}', + ); + }); + + it('concurrent undo/redo of text.edit', async function () { + interface TestDoc { + text: Text; + } + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.text = new Text(); + }, 'init doc'); + await client1.sync(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.update((root) => root.text.edit(0, 0, '123456')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal('{"text":[{"val":"123456"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"123456"}]}', doc2.toSortedJSON()); + + doc2.update((root) => root.text.edit(2, 4, 'CD')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal( + '{"text":[{"val":"12"},{"val":"CD"},{"val":"56"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"12"},{"val":"CD"},{"val":"56"}]}', + doc2.toSortedJSON(), + ); + + doc1.history.undo(); + assert.equal('{"text":[{"val":"CD"}]}', doc1.toSortedJSON()); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal('{"text":[{"val":"CD"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"CD"}]}', doc2.toSortedJSON()); + + doc1.history.redo(); + + assert.equal( + '{"text":[{"val":"12"},{"val":"CD"},{"val":"34"},{"val":"56"}]}', + doc1.toSortedJSON(), + ); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"12"},{"val":"CD"},{"val":"34"},{"val":"56"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"12"},{"val":"CD"},{"val":"34"},{"val":"56"}]}', + doc2.toSortedJSON(), + ); + }); + + it('concurrent undo/redo of text.edit 2', async function () { + interface TestDoc { + text: Text; + } + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.text = new Text(); + }, 'init doc'); + await client1.sync(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.update((root) => root.text.edit(0, 0, '123456')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal('{"text":[{"val":"123456"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"123456"}]}', doc2.toSortedJSON()); + + doc2.update((root) => root.text.edit(0, 0, 'ABC')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal( + '{"text":[{"val":"ABC"},{"val":"123456"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"ABC"},{"val":"123456"}]}', + doc2.toSortedJSON(), + ); + + doc1.history.undo(); + assert.equal('{"text":[{"val":"ABC"}]}', doc1.toSortedJSON()); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal('{"text":[{"val":"ABC"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"ABC"}]}', doc2.toSortedJSON()); + + doc1.history.redo(); + + assert.equal( + '{"text":[{"val":"ABC"},{"val":"123456"}]}', + doc1.toSortedJSON(), + ); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"ABC"},{"val":"123456"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"ABC"},{"val":"123456"}]}', + doc2.toSortedJSON(), + ); + }); + + it('concurrent undo/redo of text.edit 3', async function () { + interface TestDoc { + text: Text; + } + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.text = new Text(); + }, 'init doc'); + await client1.sync(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.update((root) => root.text.edit(0, 0, '123456')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal('{"text":[{"val":"123456"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"123456"}]}', doc2.toSortedJSON()); + + doc2.update((root) => root.text.edit(3, 6, '')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal('{"text":[{"val":"123"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"123"}]}', doc2.toSortedJSON()); + + doc1.history.undo(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal('{"text":[]}', doc1.toSortedJSON()); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.history.redo(); + + assert.equal( + '{"text":[{"val":"123"},{"val":"456"}]}', + doc1.toSortedJSON(), + ); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"123"},{"val":"456"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"123"},{"val":"456"}]}', + doc2.toSortedJSON(), + ); + }); + + it('concurrent undo/redo of text.edit 4', async function () { + interface TestDoc { + text: Text; + } + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.text = new Text(); + }, 'init doc'); + await client1.sync(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.update((root) => root.text.edit(0, 0, '123456')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal('{"text":[{"val":"123456"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"123456"}]}', doc2.toSortedJSON()); + + doc2.update((root) => root.text.edit(4, 4, 'ABC')); + await client1.sync(); + await client2.sync(); + await client1.sync(); + assert.equal( + '{"text":[{"val":"1234"},{"val":"ABC"},{"val":"56"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"1234"},{"val":"ABC"},{"val":"56"}]}', + doc2.toSortedJSON(), + ); + + doc1.history.undo(); + assert.equal('{"text":[{"val":"ABC"}]}', doc1.toSortedJSON()); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal('{"text":[{"val":"ABC"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"ABC"}]}', doc2.toSortedJSON()); + + doc1.history.redo(); + + assert.equal( + '{"text":[{"val":"1234"},{"val":"ABC"},{"val":"56"}]}', + doc1.toSortedJSON(), + ); + + await client1.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"1234"},{"val":"ABC"},{"val":"56"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"1234"},{"val":"ABC"},{"val":"56"}]}', + doc2.toSortedJSON(), + ); + }); + + it('concurrent undo/redo of text.edit must turn off GC 1', async function () { + interface TestDoc { + text: Text; + } + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey); + const doc2 = new yorkie.Document(docKey); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.text = new Text(); + }, 'init doc'); + await client1.sync(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.update((root) => root.text.edit(0, 0, 'ABC')); + await client1.sync(); + await client2.sync(); + + assert.equal('{"text":[{"val":"ABC"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"ABC"}]}', doc2.toSortedJSON()); + + doc2.update((root) => root.text.edit(3, 3, 'DEF')); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"ABC"},{"val":"DEF"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"ABC"},{"val":"DEF"}]}', + doc2.toSortedJSON(), + ); + + doc1.update((root) => root.text.edit(2, 4, '1')); + await client1.sync(); + await client2.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"AB"},{"val":"1"},{"val":"EF"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"AB"},{"val":"1"},{"val":"EF"}]}', + doc2.toSortedJSON(), + ); + + console.log(doc1.toSortedJSON(), doc1.getGarbageLen()); + + doc1.update((root) => root.text.edit(1, 4, '2')); + await client1.sync(); + await client2.sync(); + + assert.equal( + '{"text":[{"val":"A"},{"val":"2"},{"val":"F"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"A"},{"val":"2"},{"val":"F"}]}', + doc2.toSortedJSON(), + ); + + doc1.history.undo(); + assert.equal( + '{"text":[{"val":"A"},{"val":"B"},{"val":"1"},{"val":"E"},{"val":"F"}]}', + doc1.toSortedJSON(), + ); + + assert.throws( + () => { + doc1.history.undo(); + }, + Error, + 'the node of the given id should be found', + ); + + client1.detach(doc1); + client2.detach(doc2); + }); + + it('concurrent undo/redo of text.edit must turn off GC 2', async function () { + interface TestDoc { + text: Text; + } + const docKey = toDocKey(`${this.test!.title}-${new Date().getTime()}`); + const doc1 = new yorkie.Document(docKey, { disableGC: true }); + const doc2 = new yorkie.Document(docKey, { disableGC: true }); + + const client1 = new yorkie.Client(testRPCAddr); + const client2 = new yorkie.Client(testRPCAddr); + await client1.activate(); + await client2.activate(); + + await client1.attach(doc1, { isRealtimeSync: false }); + doc1.update((root) => { + root.text = new Text(); + }, 'init doc'); + await client1.sync(); + assert.equal('{"text":[]}', doc1.toSortedJSON()); + + await client2.attach(doc2, { isRealtimeSync: false }); + assert.equal('{"text":[]}', doc2.toSortedJSON()); + + doc1.update((root) => root.text.edit(0, 0, 'ABC')); + await client1.sync(); + await client2.sync(); + + assert.equal('{"text":[{"val":"ABC"}]}', doc1.toSortedJSON()); + assert.equal('{"text":[{"val":"ABC"}]}', doc2.toSortedJSON()); + + doc2.update((root) => root.text.edit(3, 3, 'DEF')); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"ABC"},{"val":"DEF"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"ABC"},{"val":"DEF"}]}', + doc2.toSortedJSON(), + ); + + doc1.update((root) => root.text.edit(2, 4, '1')); + await client1.sync(); + await client2.sync(); + await client2.sync(); + await client1.sync(); + + assert.equal( + '{"text":[{"val":"AB"},{"val":"1"},{"val":"EF"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"AB"},{"val":"1"},{"val":"EF"}]}', + doc2.toSortedJSON(), + ); + + console.log(doc1.toSortedJSON(), doc1.getGarbageLen()); + + doc1.update((root) => root.text.edit(1, 4, '2')); + await client1.sync(); + await client2.sync(); + + assert.equal( + '{"text":[{"val":"A"},{"val":"2"},{"val":"F"}]}', + doc1.toSortedJSON(), + ); + assert.equal( + '{"text":[{"val":"A"},{"val":"2"},{"val":"F"}]}', + doc2.toSortedJSON(), + ); + + doc1.history.undo(); + assert.equal( + '{"text":[{"val":"A"},{"val":"B"},{"val":"1"},{"val":"E"},{"val":"F"}]}', + doc1.toSortedJSON(), + ); + + doc1.history.undo(); + assert.equal( + '{"text":[{"val":"A"},{"val":"B"},{"val":"C"},{"val":"D"},{"val":"E"},{"val":"F"}]}', + doc1.toSortedJSON(), + ); + + client1.detach(doc1); + client2.detach(doc2); + }); + }); }); describe('peri-text example: text concurrent edit', function () {