From 7a556cf1fdb84ba69f6ee690b62397c47a8a4ac4 Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:08:42 -0400 Subject: [PATCH] Merge pull request #26041 from overleaf/em-history-ot-type-serialize History OT type: operate on parsed EditOperations GitOrigin-RevId: dbb35789736958d4ef398e566400d6e9a0e49e8b --- .../features/ide-react/editor/share-js-doc.ts | 21 +++- .../editor/share-js-history-ot-type.ts | 117 ++++++++---------- .../ide-react/editor/types/document.ts | 2 + .../source-editor/extensions/realtime.ts | 2 +- 4 files changed, 71 insertions(+), 71 deletions(-) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index e94de4e88b..5b362299d2 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -18,8 +18,15 @@ import { EditorFacade } from '@/features/source-editor/extensions/realtime' import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event' import getMeta from '@/utils/meta' import { historyOTType } from './share-js-history-ot-type' -import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index' -import { StringFileRawData } from 'overleaf-editor-core/lib/types' +import { + StringFileData, + TrackedChangeList, + EditOperationBuilder, +} from 'overleaf-editor-core' +import { + StringFileRawData, + RawEditOperation, +} from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -259,7 +266,15 @@ export class ShareJsDoc extends EventEmitter { // issues are resolved. processUpdateFromServer(message: Message) { try { - this._doc._onMessage(message) + if (this.type === 'history-ot' && message.op != null) { + const ops = message.op as RawEditOperation[] + this._doc._onMessage({ + ...message, + op: ops.map(EditOperationBuilder.fromJSON), + }) + } else { + this._doc._onMessage(message) + } } catch (error) { // Version mismatches are thrown as errors debugConsole.log(error) diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts index 2832ca390e..4621fd07fb 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts @@ -1,5 +1,5 @@ import { - EditOperationBuilder, + EditOperation, EditOperationTransformer, InsertOp, RemoveOp, @@ -7,7 +7,6 @@ import { StringFileData, TextOperation, } from 'overleaf-editor-core' -import { RawEditOperation } from 'overleaf-editor-core/lib/types' import { ShareDoc } from '../../../../../types/share-doc' type Api = { @@ -32,90 +31,74 @@ const api: Api & ThisType = { }, _register() { - this.on( - 'remoteop', - (rawEditOperation: RawEditOperation[], oldSnapshot: StringFileData) => { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) - if (operation instanceof TextOperation) { - const str = oldSnapshot.getContent() - if (str.length !== operation.baseLength) - throw new TextOperation.ApplyError( - "The operation's base length must be equal to the string's length.", - operation, - str - ) + this.on('remoteop', (ops: EditOperation[], oldSnapshot: StringFileData) => { + const operation = ops[0] + if (operation instanceof TextOperation) { + const str = oldSnapshot.getContent() + if (str.length !== operation.baseLength) + throw new TextOperation.ApplyError( + "The operation's base length must be equal to the string's length.", + operation, + str + ) - let outputCursor = 0 - let inputCursor = 0 - let trackedChangesInvalidated = false - for (const op of operation.ops) { - if (op instanceof RetainOp) { - inputCursor += op.length - outputCursor += op.length - if (op.tracking != null) { - trackedChangesInvalidated = true - } - } else if (op instanceof InsertOp) { - this.emit( - 'insert', - outputCursor, - op.insertion, - op.insertion.length - ) - outputCursor += op.insertion.length - trackedChangesInvalidated = true - } else if (op instanceof RemoveOp) { - this.emit( - 'delete', - outputCursor, - str.slice(inputCursor, inputCursor + op.length) - ) - inputCursor += op.length + let outputCursor = 0 + let inputCursor = 0 + let trackedChangesInvalidated = false + for (const op of operation.ops) { + if (op instanceof RetainOp) { + inputCursor += op.length + outputCursor += op.length + if (op.tracking != null) { trackedChangesInvalidated = true } - } - - if (inputCursor !== str.length) { - throw new TextOperation.ApplyError( - "The operation didn't operate on the whole string.", - operation, - str + } else if (op instanceof InsertOp) { + this.emit('insert', outputCursor, op.insertion, op.insertion.length) + outputCursor += op.insertion.length + trackedChangesInvalidated = true + } else if (op instanceof RemoveOp) { + this.emit( + 'delete', + outputCursor, + str.slice(inputCursor, inputCursor + op.length) ) - } - - if (trackedChangesInvalidated) { - this.emit('tracked-changes-invalidated') + inputCursor += op.length + trackedChangesInvalidated = true } } + + if (inputCursor !== str.length) { + throw new TextOperation.ApplyError( + "The operation didn't operate on the whole string.", + operation, + str + ) + } + + if (trackedChangesInvalidated) { + this.emit('tracked-changes-invalidated') + } } - ) + }) }, } export const historyOTType = { api, - transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = EditOperationTransformer.transform( - EditOperationBuilder.fromJSON(raw1[0]), - EditOperationBuilder.fromJSON(raw2[0]) - ) - return [[a.toJSON()], [b.toJSON()]] + transformX(ops1: EditOperation[], ops2: EditOperation[]) { + const [a, b] = EditOperationTransformer.transform(ops1[0], ops2[0]) + return [[a], [b]] }, - apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + apply(snapshot: StringFileData, ops: EditOperation[]) { const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(operation) + afterFile.edit(ops[0]) return afterFile }, - compose(op1: RawEditOperation[], op2: RawEditOperation[]) { - return [ - EditOperationBuilder.fromJSON(op1[0]) - .compose(EditOperationBuilder.fromJSON(op2[0])) - .toJSON(), - ] + compose(ops1: EditOperation[], ops2: EditOperation[]) { + return [ops1[0].compose(ops2[0])] }, // Do not provide normalize, used by submitOp to fixup bad input. diff --git a/services/web/frontend/js/features/ide-react/editor/types/document.ts b/services/web/frontend/js/features/ide-react/editor/types/document.ts index fbed3ab8f1..f6e5f6aebb 100644 --- a/services/web/frontend/js/features/ide-react/editor/types/document.ts +++ b/services/web/frontend/js/features/ide-react/editor/types/document.ts @@ -1,5 +1,6 @@ import { StringFileData } from 'overleaf-editor-core' import { AnyOperation } from '../../../../../../types/change' +import { RawEditOperation } from 'overleaf-editor-core/lib/types' export type Version = number @@ -36,4 +37,5 @@ export type Message = { doc?: string snapshot?: string | StringFileData type?: ShareJsTextType + op?: AnyOperation[] | RawEditOperation[] } diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 72ad016f41..1797cbc17e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -360,7 +360,7 @@ class HistoryOTAdapter { let snapshotUpdated = false for (const effect of transaction.effects) { if (effect.is(historyOTOperationEffect)) { - this.shareDoc.submitOp(effect.value.map(op => op.toJSON())) + this.shareDoc.submitOp(effect.value) snapshotUpdated = true } }