Merge pull request #25910 from overleaf/em-track-changes-sharejs

Track changes in the history OT sharejs doc

GitOrigin-RevId: 17365219f24a25790eac611dbde9681eb73d0961
This commit is contained in:
Eric Mc Sween
2025-06-04 08:07:50 +00:00
committed by Copybot
parent d173bdf8e2
commit f11ea06c1a
12 changed files with 577 additions and 102 deletions
@@ -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<React.PropsWithChildren> = ({
const { view, setView } = useLayoutContext()
const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
useModalsContext()
const { id: userId } = useUserContext()
const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
'editor.showSymbolPalette'
@@ -309,7 +311,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
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<React.PropsWithChildren> = ({
tryToggle()
},
[setTrackChanges]
[setTrackChanges, userId]
)
const doOpenNewDocument = useCallback(
@@ -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)
}
}
@@ -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'))
})
}
@@ -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<Api & ShareDoc & { snapshot: StringFileData }> = {
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) {}
}