|
|
@@ -1,4 +1,9 @@
|
|
|
-import { Decoration, EditorView, WidgetType } from '@codemirror/view'
|
|
|
+import {
|
|
|
+ Decoration,
|
|
|
+ DecorationSet,
|
|
|
+ EditorView,
|
|
|
+ WidgetType,
|
|
|
+} from '@codemirror/view'
|
|
|
import {
|
|
|
EditorState,
|
|
|
StateEffect,
|
|
|
@@ -7,29 +12,31 @@ import {
|
|
|
} from '@codemirror/state'
|
|
|
import {
|
|
|
CommentList,
|
|
|
- EditOperation,
|
|
|
TextOperation,
|
|
|
TrackingProps,
|
|
|
TrackedChangeList,
|
|
|
} from 'overleaf-editor-core'
|
|
|
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
|
|
import { HistoryOTShareDoc } from '../../../../../types/share-doc'
|
|
|
+import {
|
|
|
+ TrackedDeletes,
|
|
|
+ trackedDeletesFromState,
|
|
|
+} from '@/features/source-editor/utils/tracked-deletes'
|
|
|
|
|
|
export const historyOT = (currentDoc: DocumentContainer) => {
|
|
|
const trackedChanges =
|
|
|
- currentDoc.doc?.getTrackedChanges() ?? new TrackedChangeList([])
|
|
|
- const positionMapper = new PositionMapper(trackedChanges)
|
|
|
+ currentDoc.historyOTShareDoc.snapshot.getTrackedChanges() ??
|
|
|
+ new TrackedChangeList([])
|
|
|
+ const comments =
|
|
|
+ currentDoc.historyOTShareDoc.snapshot.getComments() ?? new CommentList([])
|
|
|
return [
|
|
|
updateSender,
|
|
|
trackChangesUserIdState,
|
|
|
shareDocState.init(() => currentDoc?.doc?._doc ?? null),
|
|
|
- commentsState,
|
|
|
- trackedChangesState.init(() => ({
|
|
|
- decorations: buildTrackedChangesDecorations(
|
|
|
- trackedChanges,
|
|
|
- positionMapper
|
|
|
- ),
|
|
|
- positionMapper,
|
|
|
+ rangesState.init(() => ({
|
|
|
+ trackedChanges,
|
|
|
+ comments,
|
|
|
+ decorations: buildRangesDecorations({ trackedChanges, comments }),
|
|
|
})),
|
|
|
trackedChangesTheme,
|
|
|
]
|
|
|
@@ -93,35 +100,66 @@ const trackedChangesTheme = EditorView.baseTheme({
|
|
|
},
|
|
|
})
|
|
|
|
|
|
-export const updateTrackedChangesEffect =
|
|
|
- StateEffect.define<TrackedChangeList>()
|
|
|
+export const rangesUpdatedEffect = StateEffect.define()
|
|
|
+
|
|
|
+const buildRangesDecorations = ({
|
|
|
+ trackedChanges,
|
|
|
+ comments,
|
|
|
+}: {
|
|
|
+ trackedChanges: TrackedChangeList
|
|
|
+ comments: CommentList
|
|
|
+}) => {
|
|
|
+ if (trackedChanges.length === 0 && comments.length === 0) {
|
|
|
+ return Decoration.none
|
|
|
+ }
|
|
|
+
|
|
|
+ const trackedDeletes = new TrackedDeletes(trackedChanges)
|
|
|
|
|
|
-const buildTrackedChangesDecorations = (
|
|
|
- trackedChanges: TrackedChangeList,
|
|
|
- positionMapper: PositionMapper
|
|
|
-) => {
|
|
|
const decorations = []
|
|
|
for (const change of trackedChanges.asSorted()) {
|
|
|
+ const from = trackedDeletes.toCodeMirror(change.range.pos)
|
|
|
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)
|
|
|
+ const to = trackedDeletes.toCodeMirror(change.range.end)
|
|
|
+ if (from < to) {
|
|
|
+ decorations.push(
|
|
|
+ Decoration.mark({
|
|
|
+ class: 'ol-cm-change ol-cm-change-i',
|
|
|
+ tracking: change.tracking,
|
|
|
+ rangeType: 'trackedChange',
|
|
|
+ change,
|
|
|
+ }).range(from, to)
|
|
|
)
|
|
|
- )
|
|
|
+ }
|
|
|
} else {
|
|
|
decorations.push(
|
|
|
Decoration.widget({
|
|
|
widget: new ChangeDeletedWidget(),
|
|
|
side: 1,
|
|
|
- }).range(positionMapper.toCM6(change.range.pos))
|
|
|
+ rangeType: 'trackedChange',
|
|
|
+ change,
|
|
|
+ }).range(from)
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ for (const comment of comments) {
|
|
|
+ if (!comment.resolved) {
|
|
|
+ for (const range of comment.ranges) {
|
|
|
+ decorations.push(
|
|
|
+ Decoration.mark({
|
|
|
+ class: 'ol-cm-change ol-cm-change-c',
|
|
|
+ id: comment.id,
|
|
|
+ rangeType: 'comment',
|
|
|
+ comment,
|
|
|
+ }).range(
|
|
|
+ trackedDeletes.toCodeMirror(range.pos),
|
|
|
+ trackedDeletes.toCodeMirror(range.end)
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
return Decoration.set(decorations, true)
|
|
|
}
|
|
|
|
|
|
@@ -138,29 +176,38 @@ class ChangeDeletedWidget extends WidgetType {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-export const trackedChangesState = StateField.define({
|
|
|
+export const rangesState = StateField.define<{
|
|
|
+ comments: CommentList
|
|
|
+ trackedChanges: TrackedChangeList
|
|
|
+ decorations: DecorationSet
|
|
|
+}>({
|
|
|
create() {
|
|
|
- return {
|
|
|
- decorations: Decoration.none,
|
|
|
- positionMapper: new PositionMapper(new TrackedChangeList([])),
|
|
|
- }
|
|
|
+ const trackedChanges = new TrackedChangeList([])
|
|
|
+ const comments = new CommentList([])
|
|
|
+ const decorations = buildRangesDecorations({ trackedChanges, comments })
|
|
|
+ return { trackedChanges, comments, decorations }
|
|
|
},
|
|
|
|
|
|
update(value, transaction) {
|
|
|
- if (
|
|
|
- (transaction.docChanged && !transaction.annotation(Transaction.remote)) ||
|
|
|
- transaction.effects.some(effect => effect.is(updateTrackedChangesEffect))
|
|
|
- ) {
|
|
|
- const shareDoc = transaction.startState.field(shareDocState)
|
|
|
- if (shareDoc != null) {
|
|
|
- const trackedChanges = shareDoc.snapshot.getTrackedChanges()
|
|
|
- const positionMapper = new PositionMapper(trackedChanges)
|
|
|
- value = {
|
|
|
- decorations: buildTrackedChangesDecorations(
|
|
|
+ const shareDoc = transaction.state.field(shareDocState)!
|
|
|
+ const { snapshot } = shareDoc
|
|
|
+
|
|
|
+ if (transaction.docChanged) {
|
|
|
+ const trackedChanges = snapshot.getTrackedChanges()
|
|
|
+ const comments = snapshot.getComments()
|
|
|
+ const decorations = buildRangesDecorations({ trackedChanges, comments })
|
|
|
+ value = { trackedChanges, comments, decorations }
|
|
|
+ } else {
|
|
|
+ for (const effect of transaction.effects) {
|
|
|
+ if (effect.is(rangesUpdatedEffect)) {
|
|
|
+ const trackedChanges = snapshot.getTrackedChanges()
|
|
|
+ const comments = snapshot.getComments()
|
|
|
+ const decorations = buildRangesDecorations({
|
|
|
trackedChanges,
|
|
|
- positionMapper
|
|
|
- ),
|
|
|
- positionMapper,
|
|
|
+ comments,
|
|
|
+ })
|
|
|
+ value = { trackedChanges, comments, decorations }
|
|
|
+ shareDoc.emit('ranges:dirty')
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
@@ -196,64 +243,16 @@ const trackChangesUserIdState = StateField.define<string | null>({
|
|
|
},
|
|
|
})
|
|
|
|
|
|
-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 updateSender = EditorState.transactionExtender.of(tr => {
|
|
|
if (!tr.docChanged || tr.annotation(Transaction.remote)) {
|
|
|
return {}
|
|
|
}
|
|
|
|
|
|
const trackingUserId = tr.startState.field(trackChangesUserIdState)
|
|
|
- const positionMapper = tr.startState.field(trackedChangesState).positionMapper
|
|
|
+ const trackedDeletes = trackedDeletesFromState(tr.startState)
|
|
|
const startDoc = tr.startState.doc
|
|
|
const opBuilder = new OperationBuilder(
|
|
|
- positionMapper.toSnapshot(startDoc.length)
|
|
|
+ trackedDeletes.toSnapshot(startDoc.length)
|
|
|
)
|
|
|
|
|
|
if (trackingUserId == null) {
|
|
|
@@ -261,14 +260,14 @@ const updateSender = EditorState.transactionExtender.of(tr => {
|
|
|
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
|
|
// insert
|
|
|
if (inserted.length > 0) {
|
|
|
- const pos = positionMapper.toSnapshot(fromA)
|
|
|
+ const pos = trackedDeletes.toSnapshot(fromA)
|
|
|
opBuilder.insert(pos, inserted.toString())
|
|
|
}
|
|
|
|
|
|
// deletion
|
|
|
if (toA > fromA) {
|
|
|
- const start = positionMapper.toSnapshot(fromA)
|
|
|
- const end = positionMapper.toSnapshot(toA)
|
|
|
+ const start = trackedDeletes.toSnapshot(fromA)
|
|
|
+ const end = trackedDeletes.toSnapshot(toA)
|
|
|
opBuilder.delete(start, end - start)
|
|
|
}
|
|
|
})
|
|
|
@@ -278,7 +277,7 @@ const updateSender = EditorState.transactionExtender.of(tr => {
|
|
|
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
|
|
// insertion
|
|
|
if (inserted.length > 0) {
|
|
|
- const pos = positionMapper.toSnapshot(fromA)
|
|
|
+ const pos = trackedDeletes.toSnapshot(fromA)
|
|
|
opBuilder.trackedInsert(
|
|
|
pos,
|
|
|
inserted.toString(),
|
|
|
@@ -289,8 +288,8 @@ const updateSender = EditorState.transactionExtender.of(tr => {
|
|
|
|
|
|
// deletion
|
|
|
if (toA > fromA) {
|
|
|
- const start = positionMapper.toSnapshot(fromA)
|
|
|
- const end = positionMapper.toSnapshot(toA)
|
|
|
+ const start = trackedDeletes.toSnapshot(fromA)
|
|
|
+ const end = trackedDeletes.toSnapshot(toA)
|
|
|
opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp)
|
|
|
}
|
|
|
})
|
|
|
@@ -376,74 +375,3 @@ 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: () => 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)
|
|
|
- }
|
|
|
-}
|