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:
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user