mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-03 14:19:01 +02:00
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) {}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user