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-03 12:08:27 -04:00
committed by Copybot
parent d173bdf8e2
commit f11ea06c1a
12 changed files with 578 additions and 103 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) {}
}
@@ -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<TrackedChangeList>()
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<string | null>()
export const setTrackChangesUserId = (userId: string | null) => {
return {
effects: setTrackChangesUserIdEffect.of(userId),
}
}
const trackChangesUserIdState = StateField.define<string | null>({
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<CommentList>()
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<EditOperation[]>()
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
}
}
@@ -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<string, any>): 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),
@@ -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()
@@ -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])
+3 -2
View File
@@ -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 {
@@ -198,7 +198,7 @@ const mockDoc = (content: string, changes: Array<Record<string, any>> = []) => {
setTrackChangesIdSeeds: () => {
// Do nothing
},
setTrackingChanges: () => {
setTrackChangesUserId: () => {
// Do nothing
},
getTrackingChanges: () => {
@@ -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,
+15 -1
View File
@@ -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