|
|
@@ -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)',
|
|
|
+export const shareDocState = StateField.define<HistoryOTShareDoc | null>({
|
|
|
+ create() {
|
|
|
+ return null
|
|
|
},
|
|
|
- '.comment': {
|
|
|
- backgroundColor: 'rgba(255, 255, 0, 0.2)',
|
|
|
+
|
|
|
+ update(value, transaction) {
|
|
|
+ // this state is constant
|
|
|
+ return value
|
|
|
},
|
|
|
})
|
|
|
|
|
|
-const updateTrackedChangesEffect = StateEffect.define<TrackedChangeList>()
|
|
|
+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 updateTrackedChanges = (trackedChanges: TrackedChangeList) => {
|
|
|
- return {
|
|
|
- effects: updateTrackedChangesEffect.of(trackedChanges),
|
|
|
+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)
|
|
|
}
|
|
|
|
|
|
-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
|
|
|
- )
|
|
|
+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
|
|
|
+ }
|
|
|
|
|
|
-const trackedChangesState = StateField.define({
|
|
|
+ eq(old: ChangeDeletedWidget) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export const trackedChangesState = StateField.define({
|
|
|
create() {
|
|
|
- return Decoration.none
|
|
|
+ return {
|
|
|
+ decorations: Decoration.none,
|
|
|
+ positionMapper: new PositionMapper(new TrackedChangeList([])),
|
|
|
+ }
|
|
|
},
|
|
|
|
|
|
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)
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+}
|