From f11ea06c1a399f65e24c0a30e1ebdbbe12a50e0a Mon Sep 17 00:00:00 2001 From: Eric Mc Sween <5454374+emcsween@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:08:27 -0400 Subject: [PATCH] Merge pull request #25910 from overleaf/em-track-changes-sharejs Track changes in the history OT sharejs doc GitOrigin-RevId: 17365219f24a25790eac611dbde9681eb73d0961 --- .../context/editor-manager-context.tsx | 6 +- .../ide-react/editor/document-container.ts | 8 +- .../features/ide-react/editor/share-js-doc.ts | 32 +- .../editor/share-js-history-ot-type.ts | 130 ++++---- .../source-editor/extensions/history-ot.ts | 290 ++++++++++++++++++ .../source-editor/extensions/index.ts | 5 +- .../source-editor/extensions/realtime.ts | 169 +++++++++- .../hooks/use-codemirror-scope.ts | 4 +- .../web/frontend/js/vendor/libs/sharejs.js | 5 +- .../source-editor/source-editor.stories.tsx | 2 +- .../source-editor/helpers/mock-doc.ts | 14 +- services/web/types/share-doc.ts | 16 +- 12 files changed, 578 insertions(+), 103 deletions(-) create mode 100644 services/web/frontend/js/features/source-editor/extensions/history-ot.ts diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index e1bb49c39c..e830d7ec1a 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -18,6 +18,7 @@ import { useConnectionContext } from '@/features/ide-react/context/connection-co import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' import { useLayoutContext } from '@/shared/context/layout-context' +import { useUserContext } from '@/shared/context/user-context' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { Doc } from '../../../../../types/doc' import { useFileTreeData } from '@/shared/context/file-tree-data-context' @@ -99,6 +100,7 @@ export const EditorManagerProvider: FC = ({ const { view, setView } = useLayoutContext() const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } = useModalsContext() + const { id: userId } = useUserContext() const [showSymbolPalette, setShowSymbolPalette] = useScopeValue( 'editor.showSymbolPalette' @@ -309,7 +311,7 @@ export const EditorManagerProvider: FC = ({ const tryToggle = () => { const saved = doc.getInflightOp() == null && doc.getPendingOp() == null if (saved) { - doc.setTrackingChanges(want) + doc.setTrackChangesUserId(want ? userId : null) setTrackChanges(want) } else { syncTimeoutRef.current = window.setTimeout(tryToggle, 100) @@ -318,7 +320,7 @@ export const EditorManagerProvider: FC = ({ tryToggle() }, - [setTrackChanges] + [setTrackChanges, userId] ) const doOpenNewDocument = useCallback( diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index fee359f146..2ded041fb1 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -196,9 +196,13 @@ export class DocumentContainer extends EventEmitter { return this.doc?.hasBufferedOps() } - setTrackingChanges(track_changes: boolean) { + setTrackChangesUserId(userId: string | null) { + this.track_changes_as = userId if (this.doc) { - this.doc.track_changes = track_changes + this.doc.setTrackChangesUserId(userId) + } + if (this.cm6) { + this.cm6.setTrackChangesUserId(userId) } } 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 a773684dcb..e94de4e88b 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 @@ -12,18 +12,14 @@ import { Message, ShareJsConnectionState, ShareJsOperation, - ShareJsTextType, TrackChangesIdSeeds, } from '@/features/ide-react/editor/types/document' 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 } from 'overleaf-editor-core/index' -import { - RawEditOperation, - StringFileRawData, -} from 'overleaf-editor-core/lib/types' +import { historyOTType } from './share-js-history-ot-type' +import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index' +import { StringFileRawData } from 'overleaf-editor-core/lib/types' // All times below are in milliseconds const SINGLE_USER_FLUSH_DELAY = 2000 @@ -68,19 +64,17 @@ export class ShareJsDoc extends EventEmitter { readonly type: OTType = 'sharejs-text-ot' ) { super() - let sharejsType: ShareJsTextType = sharejs.types.text + let sharejsType // Decode any binary bits of data let snapshot: string | StringFileData if (this.type === 'history-ot') { snapshot = StringFileData.fromRaw( docLines as unknown as StringFileRawData ) - sharejsType = new HistoryOTType(snapshot) as ShareJsTextType< - StringFileData, - RawEditOperation[] - > + sharejsType = historyOTType } else { snapshot = docLines.map(line => decodeUtf8(line)).join('\n') + sharejsType = sharejs.types.text } this.connection = { @@ -159,6 +153,18 @@ export class ShareJsDoc extends EventEmitter { this.removeCarriageReturnCharFromShareJsDoc() } + setTrackChangesUserId(userId: string | null) { + this.track_changes = userId != null + } + + getTrackedChanges() { + if (this._doc.otType === 'history-ot') { + return this._doc.snapshot.getTrackedChanges() as TrackedChangeList + } else { + return null + } + } + private removeCarriageReturnCharFromShareJsDoc() { const doc = this._doc let nextPos @@ -365,7 +371,7 @@ export class ShareJsDoc extends EventEmitter { attachToCM6(cm6: EditorFacade) { this.attachToEditor(cm6, () => { - cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type) + cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength')) }) } 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 fde66d89a1..2832ca390e 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,4 +1,3 @@ -import EventEmitter from '@/utils/EventEmitter' import { EditOperationBuilder, EditOperationTransformer, @@ -9,75 +8,28 @@ import { TextOperation, } from 'overleaf-editor-core' import { RawEditOperation } from 'overleaf-editor-core/lib/types' +import { ShareDoc } from '../../../../../types/share-doc' -export class HistoryOTType extends EventEmitter { - // stub interface, these are actually on the Doc - api: HistoryOTType - snapshot: StringFileData +type Api = { + otType: 'history-ot' + trackChangesUserId: string | null - constructor(snapshot: StringFileData) { - super() - this.api = this - this.snapshot = snapshot - } + getText(): string + getLength(): number + _register(): void +} - transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) { - const [a, b] = EditOperationTransformer.transform( - EditOperationBuilder.fromJSON(raw1[0]), - EditOperationBuilder.fromJSON(raw2[0]) - ) - return [[a.toJSON()], [b.toJSON()]] - } - - apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { - const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) - const afterFile = StringFileData.fromRaw(snapshot.toRaw()) - afterFile.edit(operation) - this.snapshot = afterFile - return afterFile - } - - compose(op1: RawEditOperation[], op2: RawEditOperation[]) { - return [ - EditOperationBuilder.fromJSON(op1[0]) - .compose(EditOperationBuilder.fromJSON(op2[0])) - .toJSON(), - ] - } - - // Do not provide normalize, used by submitOp to fixup bad input. - // normalize(op: TextOperation) {} - - // Do not provide invert, only needed for reverting a rejected update. - // We are displaying an out-of-sync modal when an op is rejected. - // invert(op: TextOperation) {} - - // API - insert(pos: number, text: string, fromUndo: boolean) { - const old = this.getText() - const op = new TextOperation() - op.retain(pos) - op.insert(text) - op.retain(old.length - pos) - this.submitOp([op.toJSON()]) - } - - del(pos: number, length: number, fromUndo: boolean) { - const old = this.getText() - const op = new TextOperation() - op.retain(pos) - op.remove(length) - op.retain(old.length - pos - length) - this.submitOp([op.toJSON()]) - } +const api: Api & ThisType = { + otType: 'history-ot', + trackChangesUserId: null, getText() { - return this.snapshot.getContent({ filterTrackedDeletes: true }) - } + return this.snapshot.getContent() + }, getLength() { - return this.getText().length - } + return this.snapshot.getStringLength() + }, _register() { this.on( @@ -95,10 +47,14 @@ export class HistoryOTType extends EventEmitter { 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', @@ -107,6 +63,7 @@ export class HistoryOTType extends EventEmitter { op.insertion.length ) outputCursor += op.insertion.length + trackedChangesInvalidated = true } else if (op instanceof RemoveOp) { this.emit( 'delete', @@ -114,20 +71,57 @@ export class HistoryOTType extends EventEmitter { str.slice(inputCursor, inputCursor + op.length) ) inputCursor += op.length + trackedChangesInvalidated = true } } - if (inputCursor !== str.length) + 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') + } } } ) - } - - // stub-interface, provided by sharejs.Doc - submitOp(op: RawEditOperation[]) {} + }, +} + +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()]] + }, + + apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) { + const operation = EditOperationBuilder.fromJSON(rawEditOperation[0]) + const afterFile = StringFileData.fromRaw(snapshot.toRaw()) + afterFile.edit(operation) + return afterFile + }, + + compose(op1: RawEditOperation[], op2: RawEditOperation[]) { + return [ + EditOperationBuilder.fromJSON(op1[0]) + .compose(EditOperationBuilder.fromJSON(op2[0])) + .toJSON(), + ] + }, + + // Do not provide normalize, used by submitOp to fixup bad input. + // normalize(op: TextOperation) {} + + // Do not provide invert, only needed for reverting a rejected update. + // We are displaying an out-of-sync modal when an op is rejected. + // invert(op: TextOperation) {} } diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts new file mode 100644 index 0000000000..58c2a42540 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -0,0 +1,290 @@ +import { Decoration, EditorView } from '@codemirror/view' +import { + ChangeSpec, + EditorState, + StateEffect, + StateField, + Transaction, +} from '@codemirror/state' +import { + CommentList, + EditOperation, + TextOperation, + TrackingProps, + TrackedChangeList, +} from 'overleaf-editor-core' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' + +export const historyOT = (currentDoc: DocumentContainer) => { + const trackedChanges = currentDoc.doc?.getTrackedChanges() + return [ + trackChangesUserIdState, + commentsState, + trackedChanges != null + ? trackedChangesState.init(() => + buildTrackedChangesDecorations(trackedChanges) + ) + : trackedChangesState, + trackedChangesFilter, + rangesTheme, + ] +} + +const rangesTheme = EditorView.theme({ + '.tracked-change-insertion': { + backgroundColor: 'rgba(0, 255, 0, 0.2)', + }, + '.tracked-change-deletion': { + backgroundColor: 'rgba(255, 0, 0, 0.2)', + }, + '.comment': { + backgroundColor: 'rgba(255, 255, 0, 0.2)', + }, +}) + +const updateTrackedChangesEffect = StateEffect.define() + +export const updateTrackedChanges = (trackedChanges: TrackedChangeList) => { + return { + effects: updateTrackedChangesEffect.of(trackedChanges), + } +} + +const buildTrackedChangesDecorations = (trackedChanges: TrackedChangeList) => + Decoration.set( + trackedChanges.asSorted().map(change => + Decoration.mark({ + class: + change.tracking.type === 'insert' + ? 'tracked-change-insertion' + : 'tracked-change-deletion', + tracking: change.tracking, + }).range(change.range.pos, change.range.end) + ), + true + ) + +const trackedChangesState = StateField.define({ + create() { + return Decoration.none + }, + + update(value, transaction) { + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + for (const effect of transaction.effects) { + if (effect.is(updateTrackedChangesEffect)) { + value = buildTrackedChangesDecorations(effect.value) + } + } + + return value + }, + + provide(field) { + return EditorView.decorations.from(field) + }, +}) + +const setTrackChangesUserIdEffect = StateEffect.define() + +export const setTrackChangesUserId = (userId: string | null) => { + return { + effects: setTrackChangesUserIdEffect.of(userId), + } +} + +const trackChangesUserIdState = StateField.define({ + create() { + return null + }, + + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(setTrackChangesUserIdEffect)) { + value = effect.value + } + } + return value + }, +}) + +const updateCommentsEffect = StateEffect.define() + +export const updateComments = (comments: CommentList) => { + return { + effects: updateCommentsEffect.of(comments), + } +} + +const buildCommentsDecorations = (comments: CommentList) => + Decoration.set( + comments.toArray().flatMap(comment => + comment.ranges.map(range => + Decoration.mark({ + class: 'tracked-change-comment', + id: comment.id, + resolved: comment.resolved, + }).range(range.pos, range.end) + ) + ), + true + ) + +const commentsState = StateField.define({ + create() { + return Decoration.none // TODO: init from snapshot + }, + + update(value, transaction) { + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + for (const effect of transaction.effects) { + if (effect.is(updateCommentsEffect)) { + value = buildCommentsDecorations(effect.value) + } + } + + return value + }, + + provide(field) { + return EditorView.decorations.from(field) + }, +}) + +export const historyOTOperationEffect = StateEffect.define() + +const trackedChangesFilter = EditorState.transactionFilter.of(tr => { + if (!tr.docChanged || tr.annotation(Transaction.remote)) { + return tr + } + + const trackingUserId = tr.startState.field(trackChangesUserIdState) + const startDoc = tr.startState.doc + const changes: ChangeSpec[] = [] + const opBuilder = new OperationBuilder(startDoc.length) + + if (trackingUserId == null) { + // Not tracking changes + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + // insert + if (inserted.length > 0) { + opBuilder.insert(fromA, inserted.toString()) + } + + // deletion + if (toA > fromA) { + opBuilder.delete(fromA, toA - fromA) + } + }) + } else { + // Tracking changes + const timestamp = new Date() + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + // insertion + if (inserted.length > 0) { + opBuilder.trackedInsert( + fromA, + inserted.toString(), + trackingUserId, + timestamp + ) + } + + // deletion + if (toA > fromA) { + const deleted = startDoc.sliceString(fromA, toA) + // re-insert the deleted text after the inserted text + changes.push({ + from: fromB + inserted.length, + insert: deleted, + }) + + opBuilder.trackedDelete(fromA, toA - fromA, trackingUserId, timestamp) + } + }) + } + + const op = opBuilder.finish() + return [ + tr, + { changes, effects: historyOTOperationEffect.of([op]), sequential: true }, + ] +}) + +/** + * Incrementally builds a TextOperation from a series of inserts and deletes. + * + * This relies on inserts and deletes being ordered by document position. This + * is not clear in the documentation, but has been confirmed by Marijn in + * https://discuss.codemirror.net/t/iterators-can-be-hard-to-work-with-for-beginners/3533/10 + */ +class OperationBuilder { + /** + * Source document length + */ + private docLength: number + + /** + * Position in the source document + */ + private pos: number + + /** + * Operation built + */ + private op: TextOperation + + constructor(docLength: number) { + this.docLength = docLength + this.op = new TextOperation() + this.pos = 0 + } + + insert(pos: number, text: string) { + this.retainUntil(pos) + this.op.insert(text) + } + + delete(pos: number, length: number) { + this.retainUntil(pos) + this.op.remove(length) + this.pos += length + } + + trackedInsert(pos: number, text: string, userId: string, timestamp: Date) { + this.retainUntil(pos) + this.op.insert(text, { + tracking: new TrackingProps('insert', userId, timestamp), + }) + } + + trackedDelete(pos: number, length: number, userId: string, timestamp: Date) { + this.retainUntil(pos) + this.op.retain(length, { + tracking: new TrackingProps('delete', userId, timestamp), + }) + this.pos += length + } + + retainUntil(pos: number) { + if (pos > this.pos) { + this.op.retain(pos - this.pos) + this.pos = pos + } else if (pos < this.pos) { + throw Error( + `Out of order: position ${pos} comes before current position: ${this.pos}` + ) + } + } + + finish() { + this.retainUntil(this.docLength) + return this.op + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index 0a65739c55..0e19d42fc1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -50,6 +50,7 @@ import { docName } from './doc-name' import { fileTreeItemDrop } from './file-tree-item-drop' import { mathPreview } from './math-preview' import { ranges } from './ranges' +import { historyOT } from './history-ot' import { trackDetachedComments } from './track-detached-comments' import { reviewTooltip } from './review-tooltip' @@ -142,7 +143,9 @@ export const createExtensions = (options: Record): Extension[] => [ // NOTE: `emptyLineFiller` needs to be before `trackChanges`, // so the decorations are added in the correct order. emptyLineFiller(), - ranges(), + options.currentDoc.currentDocument.getType() === 'history-ot' + ? historyOT(options.currentDoc.currentDocument) + : ranges(), trackDetachedComments(options.currentDoc), visual(options.visual), mathPreview(options.settings.mathPreview), 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 9118e4f151..72ad016f41 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -1,11 +1,26 @@ -import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state' +import { + Prec, + Transaction, + Annotation, + ChangeSpec, + Text, +} from '@codemirror/state' import { EditorView, ViewPlugin } from '@codemirror/view' import { EventEmitter } from 'events' import RangesTracker from '@overleaf/ranges-tracker' -import { ShareDoc } from '../../../../../types/share-doc' +import { + ShareDoc, + ShareLatexOTShareDoc, + HistoryOTShareDoc, +} from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -import { OTType } from '@/features/ide-react/editor/share-js-doc' +import { TrackedChangeList } from 'overleaf-editor-core' +import { + updateTrackedChanges, + setTrackChangesUserId, + historyOTOperationEffect, +} from './history-ot' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -26,8 +41,10 @@ import { OTType } from '@/features/ide-react/editor/share-js-doc' * - frontend/js/features/ide-react/connection/editor-watchdog-manager.js */ +type Origin = 'remote' | 'undo' | 'reject' | undefined + export type ChangeDescription = { - origin: 'remote' | 'undo' | 'reject' | undefined + origin: Origin inserted: boolean removed: boolean } @@ -126,9 +143,13 @@ export class EditorFacade extends EventEmitter { this.cmChange({ from: position, to: position + text.length }, origin) } - attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) { + cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) { + this.view.dispatch(updateTrackedChanges(trackedChanges)) + } + + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { this.otAdapter = - type === 'history-ot' + shareDoc.otType === 'history-ot' ? new HistoryOTAdapter(this, shareDoc, maxDocLength) : new ShareLatexOTAdapter(this, shareDoc, maxDocLength) this.otAdapter.attachShareJs() @@ -148,12 +169,18 @@ export class EditorFacade extends EventEmitter { this.otAdapter.handleUpdateFromCM(transactions, ranges) } + + setTrackChangesUserId(userId: string | null) { + if (this.otAdapter instanceof HistoryOTAdapter) { + this.view.dispatch(setTrackChangesUserId(userId)) + } + } } class ShareLatexOTAdapter { constructor( public editor: EditorFacade, - private shareDoc: ShareDoc, + private shareDoc: ShareLatexOTShareDoc, private maxDocLength?: number ) { this.editor = editor @@ -279,7 +306,133 @@ class ShareLatexOTAdapter { } } -class HistoryOTAdapter extends ShareLatexOTAdapter {} +class HistoryOTAdapter { + constructor( + public editor: EditorFacade, + private shareDoc: HistoryOTShareDoc, + private maxDocLength?: number + ) { + this.editor = editor + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + } + + attachShareJs() { + this.checkContent() + + const onInsert = this.onShareJsInsert.bind(this) + const onDelete = this.onShareJsDelete.bind(this) + const onTrackedChangesInvalidated = + this.onShareJsTrackedChangesInvalidated.bind(this) + + this.shareDoc.on('insert', onInsert) + this.shareDoc.on('delete', onDelete) + this.shareDoc.on('tracked-changes-invalidated', onTrackedChangesInvalidated) + + this.shareDoc.detach_cm6 = () => { + this.shareDoc.removeListener('insert', onInsert) + this.shareDoc.removeListener('delete', onDelete) + this.shareDoc.removeListener( + 'tracked-changes-invalidated', + onTrackedChangesInvalidated + ) + delete this.shareDoc.detach_cm6 + this.editor.detachShareJs() + } + } + + handleUpdateFromCM( + transactions: readonly Transaction[], + ranges?: RangesTracker + ) { + for (const transaction of transactions) { + if ( + this.maxDocLength && + transaction.changes.newLength >= this.maxDocLength + ) { + this.shareDoc.emit( + 'error', + new Error('document length is greater than maxDocLength') + ) + return + } + + let snapshotUpdated = false + for (const effect of transaction.effects) { + if (effect.is(historyOTOperationEffect)) { + this.shareDoc.submitOp(effect.value.map(op => op.toJSON())) + snapshotUpdated = true + } + } + + if (snapshotUpdated || transaction.annotation(Transaction.remote)) { + window.setTimeout(() => { + this.editor.cmUpdateTrackedChanges( + this.shareDoc.snapshot.getTrackedChanges() + ) + }, 0) + } + + const origin = chooseOrigin(transaction) + transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin) + }) + } + } + + onShareJsInsert(pos: number, text: string) { + this.editor.cmInsert(pos, text, 'remote') + this.checkContent() + } + + onShareJsDelete(pos: number, text: string) { + this.editor.cmDelete(pos, text, 'remote') + this.checkContent() + } + + onShareJsTrackedChangesInvalidated() { + this.editor.cmUpdateTrackedChanges( + this.shareDoc.snapshot.getTrackedChanges() + ) + } + + onCodeMirrorChange( + fromA: number, + toA: number, + fromB: number, + toB: number, + insertedText: Text, + origin: Origin + ) { + const insertedLength = insertedText.length + const removedLength = toA - fromA + const inserted = insertedLength > 0 + const removed = removedLength > 0 + + const changeDescription: ChangeDescription = { + origin, + inserted, + removed, + } + + this.editor.emit('change', this.editor, changeDescription) + } + + checkContent() { + // run in a timeout so it checks the editor content once this update has been applied + window.setTimeout(() => { + const editorText = this.editor.getValue() + const otText = this.shareDoc.getText() + + if (editorText !== otText) { + this.shareDoc.emit('error', 'Text does not match in CodeMirror 6') + debugConsole.error('Text does not match!') + debugConsole.error('editor: ' + editorText) + debugConsole.error('ot: ' + otText) + } + }, 0) + } +} export const trackChangesAnnotation = Annotation.define() diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index a4e2862e1f..2504afdd0c 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -185,9 +185,9 @@ function useCodeMirrorScope(view: EditorView) { if (currentDocument) { if (trackChanges) { - currentDocument.track_changes_as = userId || 'anonymous' + currentDocument.setTrackChangesUserId(userId ?? 'anonymous') } else { - currentDocument.track_changes_as = null + currentDocument.setTrackChangesUserId(null) } } }, [userId, currentDocument, trackChanges]) diff --git a/services/web/frontend/js/vendor/libs/sharejs.js b/services/web/frontend/js/vendor/libs/sharejs.js index accc2b5b04..52e201ce37 100644 --- a/services/web/frontend/js/vendor/libs/sharejs.js +++ b/services/web/frontend/js/vendor/libs/sharejs.js @@ -680,6 +680,7 @@ export const { Doc } = (() => { // Text document API for text text.api = { + otType: "sharejs-text-ot", provides: { text: true }, // The number of characters in the string @@ -1008,8 +1009,8 @@ export const { Doc } = (() => { this.type = type; if (type.api) { - for (const k of ['insert', 'del', 'getText', 'getLength', '_register']) { - this[k] = type.api[k] + for (var k in type.api) { + var v = type.api[k];this[k] = v; } return typeof this._register === 'function' ? this._register() : undefined; } else { diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx index d87179b65f..3cc6b1c95f 100644 --- a/services/web/frontend/stories/source-editor/source-editor.stories.tsx +++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx @@ -198,7 +198,7 @@ const mockDoc = (content: string, changes: Array> = []) => { setTrackChangesIdSeeds: () => { // Do nothing }, - setTrackingChanges: () => { + setTrackChangesUserId: () => { // Do nothing }, getTrackingChanges: () => { diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index 4c239c1f60..f13d9ad6bb 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -1,4 +1,4 @@ -import { ShareDoc } from '../../../../../types/share-doc' +import { ShareLatexOTShareDoc } from '../../../../../types/share-doc' import { EventEmitter } from 'events' export const docId = 'test-doc' @@ -36,6 +36,9 @@ const defaultContent = mockDocContent(contentLines.join('\n')) const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength class MockShareDoc extends EventEmitter { + otType = 'sharejs-text-ot' as const + snapshot = '' + constructor(public text: string) { super() } @@ -51,16 +54,21 @@ class MockShareDoc extends EventEmitter { del() { // do nothing } + + submitOp() { + // do nothing + } } export const mockDoc = ( content = defaultContent, { rangesOptions = {} } = {} ) => { - const mockShareJSDoc: ShareDoc = new MockShareDoc(content) + const mockShareJSDoc: ShareLatexOTShareDoc = new MockShareDoc(content) return { doc_id: docId, + getType: () => 'sharejs-text-ot', getSnapshot: () => { return content }, @@ -101,7 +109,7 @@ export const mockDoc = ( submitOp: (op: any) => {}, setTrackChangesIdSeeds: () => {}, getTrackingChanges: () => true, - setTrackingChanges: () => {}, + setTrackChangesUserId: () => {}, getInflightOp: () => null, getPendingOp: () => null, hasBufferedOps: () => false, diff --git a/services/web/types/share-doc.ts b/services/web/types/share-doc.ts index d071c97f28..7c75e6d0de 100644 --- a/services/web/types/share-doc.ts +++ b/services/web/types/share-doc.ts @@ -1,9 +1,23 @@ import EventEmitter from 'events' +import { StringFileData } from 'overleaf-editor-core' // type for the Doc class in vendor/libs/sharejs.js -export interface ShareDoc extends EventEmitter { +export interface ShareLatexOTShareDoc extends EventEmitter { + otType: 'sharejs-text-ot' + snapshot: string detach_cm6?: () => void getText: () => string insert: (pos: number, insert: string, fromUndo: boolean) => void del: (pos: number, length: number, fromUndo: boolean) => void + submitOp(op: any[]): void } + +export interface HistoryOTShareDoc extends EventEmitter { + otType: 'history-ot' + snapshot: StringFileData + detach_cm6?: () => void + getText: () => string + submitOp(op: any[]): void +} + +export type ShareDoc = ShareLatexOTShareDoc | HistoryOTShareDoc