mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #26128 from overleaf/em-no-tracked-deletes-in-cm
History OT: Remove tracked deletes from CodeMirror GitOrigin-RevId: 4e7f30cf2ed90b0c261eaa4ba51a2f54fe6e3cef
This commit is contained in:
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
EditOperation,
|
||||
EditOperationTransformer,
|
||||
InsertOp,
|
||||
RemoveOp,
|
||||
RetainOp,
|
||||
StringFileData,
|
||||
TextOperation,
|
||||
} from 'overleaf-editor-core'
|
||||
import { ShareDoc } from '../../../../../types/share-doc'
|
||||
|
||||
@@ -15,7 +11,6 @@ type Api = {
|
||||
|
||||
getText(): string
|
||||
getLength(): number
|
||||
_register(): void
|
||||
}
|
||||
|
||||
const api: Api & ThisType<Api & ShareDoc & { snapshot: StringFileData }> = {
|
||||
@@ -23,64 +18,12 @@ const api: Api & ThisType<Api & ShareDoc & { snapshot: StringFileData }> = {
|
||||
trackChangesUserId: null,
|
||||
|
||||
getText() {
|
||||
return this.snapshot.getContent()
|
||||
return this.snapshot.getContent({ filterTrackedDeletes: true })
|
||||
},
|
||||
|
||||
getLength() {
|
||||
return this.snapshot.getStringLength()
|
||||
},
|
||||
|
||||
_register() {
|
||||
this.on('remoteop', (ops: EditOperation[], oldSnapshot: StringFileData) => {
|
||||
const operation = ops[0]
|
||||
if (operation instanceof TextOperation) {
|
||||
const str = oldSnapshot.getContent()
|
||||
if (str.length !== operation.baseLength)
|
||||
throw new TextOperation.ApplyError(
|
||||
"The operation's base length must be equal to the string's length.",
|
||||
operation,
|
||||
str
|
||||
)
|
||||
|
||||
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', outputCursor, op.insertion, op.insertion.length)
|
||||
outputCursor += op.insertion.length
|
||||
trackedChangesInvalidated = true
|
||||
} else if (op instanceof RemoveOp) {
|
||||
this.emit(
|
||||
'delete',
|
||||
outputCursor,
|
||||
str.slice(inputCursor, inputCursor + op.length)
|
||||
)
|
||||
inputCursor += op.length
|
||||
trackedChangesInvalidated = true
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const historyOTType = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Decoration, EditorView } from '@codemirror/view'
|
||||
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
|
||||
import {
|
||||
ChangeSpec,
|
||||
EditorState,
|
||||
@@ -14,69 +14,151 @@ import {
|
||||
TrackedChangeList,
|
||||
} from 'overleaf-editor-core'
|
||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import { HistoryOTShareDoc } from '../../../../../types/share-doc'
|
||||
|
||||
export const historyOT = (currentDoc: DocumentContainer) => {
|
||||
const trackedChanges = currentDoc.doc?.getTrackedChanges()
|
||||
const trackedChanges =
|
||||
currentDoc.doc?.getTrackedChanges() ?? new TrackedChangeList([])
|
||||
const positionMapper = new PositionMapper(trackedChanges)
|
||||
return [
|
||||
trackChangesUserIdState,
|
||||
shareDocState.init(() => currentDoc?.doc?._doc ?? null),
|
||||
commentsState,
|
||||
trackedChanges != null
|
||||
? trackedChangesState.init(() =>
|
||||
buildTrackedChangesDecorations(trackedChanges)
|
||||
)
|
||||
: trackedChangesState,
|
||||
trackedChangesState.init(() => ({
|
||||
decorations: buildTrackedChangesDecorations(
|
||||
trackedChanges,
|
||||
positionMapper
|
||||
),
|
||||
positionMapper,
|
||||
})),
|
||||
trackedChangesFilter,
|
||||
rangesTheme,
|
||||
trackedChangesTheme,
|
||||
]
|
||||
}
|
||||
|
||||
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({
|
||||
export const shareDocState = StateField.define<HistoryOTShareDoc | null>({
|
||||
create() {
|
||||
return Decoration.none
|
||||
return null
|
||||
},
|
||||
|
||||
update(value, transaction) {
|
||||
if (transaction.docChanged) {
|
||||
value = value.map(transaction.changes)
|
||||
}
|
||||
// this state is constant
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
const trackedChangesTheme = EditorView.baseTheme({
|
||||
'.ol-cm-change-i, .ol-cm-change-highlight-i, .ol-cm-change-focus-i': {
|
||||
backgroundColor: 'rgba(44, 142, 48, 0.30)',
|
||||
},
|
||||
'&light .ol-cm-change-c, &light .ol-cm-change-highlight-c, &light .ol-cm-change-focus-c':
|
||||
{
|
||||
backgroundColor: 'rgba(243, 177, 17, 0.30)',
|
||||
},
|
||||
'&dark .ol-cm-change-c, &dark .ol-cm-change-highlight-c, &dark .ol-cm-change-focus-c':
|
||||
{
|
||||
backgroundColor: 'rgba(194, 93, 11, 0.15)',
|
||||
},
|
||||
'.ol-cm-change': {
|
||||
padding: 'var(--half-leading, 0) 0',
|
||||
},
|
||||
'.ol-cm-change-highlight': {
|
||||
padding: 'var(--half-leading, 0) 0',
|
||||
},
|
||||
'.ol-cm-change-focus': {
|
||||
padding: 'var(--half-leading, 0) 0',
|
||||
},
|
||||
'&light .ol-cm-change-d': {
|
||||
borderLeft: '2px dotted #c5060b',
|
||||
marginLeft: '-1px',
|
||||
},
|
||||
'&dark .ol-cm-change-d': {
|
||||
borderLeft: '2px dotted #c5060b',
|
||||
marginLeft: '-1px',
|
||||
},
|
||||
'&light .ol-cm-change-d-highlight': {
|
||||
borderLeft: '3px solid #c5060b',
|
||||
marginLeft: '-2px',
|
||||
},
|
||||
'&dark .ol-cm-change-d-highlight': {
|
||||
borderLeft: '3px solid #c5060b',
|
||||
marginLeft: '-2px',
|
||||
},
|
||||
'&light .ol-cm-change-d-focus': {
|
||||
borderLeft: '3px solid #B83A33',
|
||||
marginLeft: '-2px',
|
||||
},
|
||||
'&dark .ol-cm-change-d-focus': {
|
||||
borderLeft: '3px solid #B83A33',
|
||||
marginLeft: '-2px',
|
||||
},
|
||||
})
|
||||
|
||||
export const updateTrackedChangesEffect =
|
||||
StateEffect.define<TrackedChangeList>()
|
||||
|
||||
const buildTrackedChangesDecorations = (
|
||||
trackedChanges: TrackedChangeList,
|
||||
positionMapper: PositionMapper
|
||||
) => {
|
||||
const decorations = []
|
||||
for (const change of trackedChanges.asSorted()) {
|
||||
if (change.tracking.type === 'insert') {
|
||||
decorations.push(
|
||||
Decoration.mark({
|
||||
class: 'ol-cm-change ol-cm-change-i',
|
||||
tracking: change.tracking,
|
||||
}).range(
|
||||
positionMapper.toCM6(change.range.pos),
|
||||
positionMapper.toCM6(change.range.end)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
decorations.push(
|
||||
Decoration.widget({
|
||||
widget: new ChangeDeletedWidget(),
|
||||
side: 1,
|
||||
}).range(positionMapper.toCM6(change.range.pos))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true)
|
||||
}
|
||||
|
||||
class ChangeDeletedWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const widget = document.createElement('span')
|
||||
widget.classList.add('ol-cm-change')
|
||||
widget.classList.add('ol-cm-change-d')
|
||||
return widget
|
||||
}
|
||||
|
||||
eq(old: ChangeDeletedWidget) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const trackedChangesState = StateField.define({
|
||||
create() {
|
||||
return {
|
||||
decorations: Decoration.none,
|
||||
positionMapper: new PositionMapper(new TrackedChangeList([])),
|
||||
}
|
||||
},
|
||||
|
||||
update(value, transaction) {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(updateTrackedChangesEffect)) {
|
||||
value = buildTrackedChangesDecorations(effect.value)
|
||||
const trackedChanges = effect.value
|
||||
const positionMapper = new PositionMapper(trackedChanges)
|
||||
value = {
|
||||
decorations: buildTrackedChangesDecorations(
|
||||
effect.value,
|
||||
positionMapper
|
||||
),
|
||||
positionMapper,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +166,7 @@ const trackedChangesState = StateField.define({
|
||||
},
|
||||
|
||||
provide(field) {
|
||||
return EditorView.decorations.from(field)
|
||||
return EditorView.decorations.from(field, value => value.decorations)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -165,21 +247,28 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => {
|
||||
}
|
||||
|
||||
const trackingUserId = tr.startState.field(trackChangesUserIdState)
|
||||
const positionMapper = tr.startState.field(trackedChangesState).positionMapper
|
||||
const startDoc = tr.startState.doc
|
||||
const changes: ChangeSpec[] = []
|
||||
const opBuilder = new OperationBuilder(startDoc.length)
|
||||
const effects = []
|
||||
const opBuilder = new OperationBuilder(
|
||||
positionMapper.toSnapshot(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())
|
||||
const pos = positionMapper.toSnapshot(fromA)
|
||||
opBuilder.insert(pos, inserted.toString())
|
||||
}
|
||||
|
||||
// deletion
|
||||
if (toA > fromA) {
|
||||
opBuilder.delete(fromA, toA - fromA)
|
||||
const start = positionMapper.toSnapshot(fromA)
|
||||
const end = positionMapper.toSnapshot(toA)
|
||||
opBuilder.delete(start, end - start)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -188,8 +277,9 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => {
|
||||
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
// insertion
|
||||
if (inserted.length > 0) {
|
||||
const pos = positionMapper.toSnapshot(fromA)
|
||||
opBuilder.trackedInsert(
|
||||
fromA,
|
||||
pos,
|
||||
inserted.toString(),
|
||||
trackingUserId,
|
||||
timestamp
|
||||
@@ -198,23 +288,23 @@ const trackedChangesFilter = EditorState.transactionFilter.of(tr => {
|
||||
|
||||
// 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 start = positionMapper.toSnapshot(fromA)
|
||||
const end = positionMapper.toSnapshot(toA)
|
||||
opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const op = opBuilder.finish()
|
||||
return [
|
||||
tr,
|
||||
{ changes, effects: historyOTOperationEffect.of([op]), sequential: true },
|
||||
]
|
||||
const shareDoc = tr.startState.field(shareDocState)
|
||||
if (shareDoc != null) {
|
||||
shareDoc.submitOp([op])
|
||||
effects.push(
|
||||
updateTrackedChangesEffect.of(shareDoc.snapshot.getTrackedChanges())
|
||||
)
|
||||
}
|
||||
|
||||
return [tr, { changes, effects, sequential: true }]
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -288,3 +378,74 @@ class OperationBuilder {
|
||||
return this.op
|
||||
}
|
||||
}
|
||||
|
||||
type OffsetTable = { pos: number; map: (pos: number) => number }[]
|
||||
|
||||
class PositionMapper {
|
||||
private offsets: {
|
||||
toCM6: OffsetTable
|
||||
toSnapshot: OffsetTable
|
||||
}
|
||||
|
||||
constructor(trackedChanges: TrackedChangeList) {
|
||||
this.offsets = {
|
||||
toCM6: [{ pos: 0, map: pos => pos }],
|
||||
toSnapshot: [{ pos: 0, map: pos => pos }],
|
||||
}
|
||||
|
||||
// Offset of the snapshot pos relative to the CM6 pos
|
||||
let offset = 0
|
||||
for (const change of trackedChanges.asSorted()) {
|
||||
if (change.tracking.type === 'delete') {
|
||||
const deleteLength = change.range.length
|
||||
const deletePos = change.range.pos
|
||||
const oldOffset = offset
|
||||
const newOffset = offset + deleteLength
|
||||
this.offsets.toSnapshot.push({
|
||||
pos: change.range.pos - offset + 1,
|
||||
map: pos => pos + newOffset,
|
||||
})
|
||||
this.offsets.toCM6.push({
|
||||
pos: change.range.pos,
|
||||
map: pos => deletePos - oldOffset,
|
||||
})
|
||||
this.offsets.toCM6.push({
|
||||
pos: change.range.pos + deleteLength,
|
||||
map: pos => pos - newOffset,
|
||||
})
|
||||
offset = newOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toCM6(snapshotPos: number) {
|
||||
return this.mapPos(snapshotPos, this.offsets.toCM6)
|
||||
}
|
||||
|
||||
toSnapshot(cm6Pos: number) {
|
||||
return this.mapPos(cm6Pos, this.offsets.toSnapshot)
|
||||
}
|
||||
|
||||
mapPos(pos: number, offsets: OffsetTable) {
|
||||
// Binary search for the offset at the last position before pos
|
||||
let low = 0
|
||||
let high = offsets.length - 1
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2)
|
||||
const entry = offsets[middle]
|
||||
if (entry.pos < pos) {
|
||||
// This entry could be the right offset, but lower entries are too low
|
||||
// Because we used Math.ceil(), middle is higher than low and the
|
||||
// algorithm progresses.
|
||||
low = middle
|
||||
} else if (entry.pos > pos) {
|
||||
// This entry is too high
|
||||
high = middle - 1
|
||||
} else {
|
||||
// This is the right entry
|
||||
return entry.map(pos)
|
||||
}
|
||||
}
|
||||
return offsets[low].map(pos)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Annotation,
|
||||
ChangeSpec,
|
||||
Text,
|
||||
StateEffect,
|
||||
} from '@codemirror/state'
|
||||
import { EditorView, ViewPlugin } from '@codemirror/view'
|
||||
import { EventEmitter } from 'events'
|
||||
@@ -15,11 +16,18 @@ import {
|
||||
} from '../../../../../types/share-doc'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import { TrackedChangeList } from 'overleaf-editor-core'
|
||||
import {
|
||||
updateTrackedChanges,
|
||||
EditOperation,
|
||||
TextOperation,
|
||||
InsertOp,
|
||||
RemoveOp,
|
||||
RetainOp,
|
||||
} from 'overleaf-editor-core'
|
||||
import {
|
||||
updateTrackedChangesEffect,
|
||||
setTrackChangesUserId,
|
||||
historyOTOperationEffect,
|
||||
trackedChangesState,
|
||||
shareDocState,
|
||||
} from './history-ot'
|
||||
|
||||
/*
|
||||
@@ -143,10 +151,6 @@ export class EditorFacade extends EventEmitter {
|
||||
this.cmChange({ from: position, to: position + text.length }, origin)
|
||||
}
|
||||
|
||||
cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) {
|
||||
this.view.dispatch(updateTrackedChanges(trackedChanges))
|
||||
}
|
||||
|
||||
attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
|
||||
this.otAdapter =
|
||||
shareDoc.otType === 'history-ot'
|
||||
@@ -320,22 +324,11 @@ class HistoryOTAdapter {
|
||||
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)
|
||||
const onRemoteOp = this.onRemoteOp.bind(this)
|
||||
this.shareDoc.on('remoteop', onRemoteOp)
|
||||
|
||||
this.shareDoc.detach_cm6 = () => {
|
||||
this.shareDoc.removeListener('insert', onInsert)
|
||||
this.shareDoc.removeListener('delete', onDelete)
|
||||
this.shareDoc.removeListener(
|
||||
'tracked-changes-invalidated',
|
||||
onTrackedChangesInvalidated
|
||||
)
|
||||
this.shareDoc.removeListener('remoteop', onRemoteOp)
|
||||
delete this.shareDoc.detach_cm6
|
||||
this.editor.detachShareJs()
|
||||
}
|
||||
@@ -357,22 +350,6 @@ class HistoryOTAdapter {
|
||||
return
|
||||
}
|
||||
|
||||
let snapshotUpdated = false
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(historyOTOperationEffect)) {
|
||||
this.shareDoc.submitOp(effect.value)
|
||||
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)
|
||||
@@ -380,20 +357,70 @@ class HistoryOTAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
onShareJsInsert(pos: number, text: string) {
|
||||
this.editor.cmInsert(pos, text, 'remote')
|
||||
this.checkContent()
|
||||
}
|
||||
onRemoteOp(operations: EditOperation[]) {
|
||||
const positionMapper =
|
||||
this.editor.view.state.field(trackedChangesState).positionMapper
|
||||
const changes: ChangeSpec[] = []
|
||||
let trackedChangesUpdated = false
|
||||
for (const operation of operations) {
|
||||
if (operation instanceof TextOperation) {
|
||||
let cursor = 0
|
||||
for (const op of operation.ops) {
|
||||
if (op instanceof InsertOp) {
|
||||
if (op.tracking?.type !== 'delete') {
|
||||
changes.push({
|
||||
from: positionMapper.toCM6(cursor),
|
||||
insert: op.insertion,
|
||||
})
|
||||
}
|
||||
trackedChangesUpdated = true
|
||||
} else if (op instanceof RemoveOp) {
|
||||
changes.push({
|
||||
from: positionMapper.toCM6(cursor),
|
||||
to: positionMapper.toCM6(cursor + op.length),
|
||||
})
|
||||
cursor += op.length
|
||||
trackedChangesUpdated = true
|
||||
} else if (op instanceof RetainOp) {
|
||||
if (op.tracking != null) {
|
||||
if (op.tracking.type === 'delete') {
|
||||
changes.push({
|
||||
from: positionMapper.toCM6(cursor),
|
||||
to: positionMapper.toCM6(cursor + op.length),
|
||||
})
|
||||
}
|
||||
trackedChangesUpdated = true
|
||||
}
|
||||
cursor += op.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onShareJsDelete(pos: number, text: string) {
|
||||
this.editor.cmDelete(pos, text, 'remote')
|
||||
this.checkContent()
|
||||
}
|
||||
const view = this.editor.view
|
||||
const effects: StateEffect<any>[] = []
|
||||
const scrollEffect = view
|
||||
.scrollSnapshot()
|
||||
.map(view.state.changes(changes))
|
||||
if (scrollEffect != null) {
|
||||
effects.push(scrollEffect)
|
||||
}
|
||||
if (trackedChangesUpdated) {
|
||||
const shareDoc = this.editor.view.state.field(shareDocState)
|
||||
if (shareDoc != null) {
|
||||
const trackedChanges = shareDoc.snapshot.getTrackedChanges()
|
||||
effects.push(updateTrackedChangesEffect.of(trackedChanges))
|
||||
}
|
||||
}
|
||||
|
||||
onShareJsTrackedChangesInvalidated() {
|
||||
this.editor.cmUpdateTrackedChanges(
|
||||
this.shareDoc.snapshot.getTrackedChanges()
|
||||
)
|
||||
view.dispatch({
|
||||
changes,
|
||||
effects,
|
||||
annotations: [
|
||||
Transaction.remote.of(true),
|
||||
Transaction.addToHistory.of(false),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onCodeMirrorChange(
|
||||
|
||||
Reference in New Issue
Block a user