mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
copied track-changes.ts extension
GitOrigin-RevId: 95e383223c6e4e7a10112ebfaff4d3bda9530a01
This commit is contained in:
parent
e19e5ae29a
commit
3bdfd36613
1 changed files with 555 additions and 0 deletions
|
@ -0,0 +1,555 @@
|
|||
import {
|
||||
EditorState,
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Transaction,
|
||||
TransactionSpec,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
type PluginValue,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
} from '@codemirror/view'
|
||||
import {
|
||||
findCommentsInCut,
|
||||
findDetachedCommentsInChanges,
|
||||
restoreCommentsOnPaste,
|
||||
restoreDetachedComments,
|
||||
StoredComment,
|
||||
} from './changes/comments'
|
||||
import { invertedEffects } from '@codemirror/commands'
|
||||
import {
|
||||
Change,
|
||||
DeleteOperation,
|
||||
EditOperation,
|
||||
} from '../../../../../types/change'
|
||||
import { ChangeManager } from './changes/change-manager'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import {
|
||||
isCommentOperation,
|
||||
isDeleteOperation,
|
||||
isInsertOperation,
|
||||
} from '@/utils/operations'
|
||||
import {
|
||||
DocumentContainer,
|
||||
RangesTrackerWithResolvedThreadIds,
|
||||
} from '@/features/ide-react/editor/document-container'
|
||||
import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
|
||||
import { Ranges } from '@/features/review-panel-new/context/ranges-context'
|
||||
import { Threads } from '@/features/review-panel-new/context/threads-context'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
|
||||
type RangesData = {
|
||||
ranges: Ranges
|
||||
threads: Threads
|
||||
}
|
||||
|
||||
const updateRangesEffect = StateEffect.define<RangesData>()
|
||||
|
||||
export const updateRanges = (data: RangesData): TransactionSpec => {
|
||||
return {
|
||||
effects: updateRangesEffect.of(data),
|
||||
}
|
||||
}
|
||||
|
||||
const clearChangesEffect = StateEffect.define()
|
||||
const buildChangesEffect = StateEffect.define()
|
||||
const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
|
||||
map: (value, mapping) => {
|
||||
return value
|
||||
.update({
|
||||
filter: (from, to) => {
|
||||
return from <= mapping.length && to <= mapping.length
|
||||
},
|
||||
})
|
||||
.map(mapping)
|
||||
},
|
||||
})
|
||||
|
||||
type Options = {
|
||||
currentDoc: DocumentContainer
|
||||
loadingThreads?: boolean
|
||||
ranges?: Ranges
|
||||
threads?: Threads
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom extension that initialises the change manager, passes any updates to it,
|
||||
* and produces decorations for tracked changes and comments.
|
||||
*/
|
||||
export const trackChanges = (
|
||||
{ currentDoc, loadingThreads, ranges, threads }: Options,
|
||||
changeManager?: ChangeManager
|
||||
) => {
|
||||
// A state field that stored any comments found within the ranges of a "cut" transaction,
|
||||
// to be restored when pasting matching text.
|
||||
const cutCommentsState = StateField.define<StoredComment[]>({
|
||||
create: () => {
|
||||
return []
|
||||
},
|
||||
update: (value, transaction) => {
|
||||
if (transaction.annotation(Transaction.remote)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (!transaction.docChanged) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (transaction.isUserEvent('delete.cut')) {
|
||||
return findCommentsInCut(currentDoc, transaction)
|
||||
}
|
||||
|
||||
if (transaction.isUserEvent('input.paste')) {
|
||||
restoreCommentsOnPaste(currentDoc, transaction, value)
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
})
|
||||
|
||||
return [
|
||||
// attach any comments detached by the transaction as an inverted effect, to be applied on undo
|
||||
invertedEffects.of(transaction => {
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
!transaction.annotation(Transaction.remote)
|
||||
) {
|
||||
const detachedComments = findDetachedCommentsInChanges(
|
||||
currentDoc,
|
||||
transaction
|
||||
)
|
||||
if (detachedComments.size) {
|
||||
return [restoreDetachedCommentsEffect.of(detachedComments)]
|
||||
}
|
||||
}
|
||||
return []
|
||||
}),
|
||||
|
||||
// restore any detached comments on undo
|
||||
EditorState.transactionExtender.of(transaction => {
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(restoreDetachedCommentsEffect)) {
|
||||
// send the comments to the ShareJS doc
|
||||
restoreDetachedComments(currentDoc, transaction, effect.value)
|
||||
|
||||
// return a transaction spec to rebuild the change markers
|
||||
return buildChangeMarkers()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
|
||||
cutCommentsState,
|
||||
|
||||
// initialize/destroy the change manager, and handle any updates
|
||||
changeManager
|
||||
? ViewPlugin.define(() => {
|
||||
changeManager.initialize()
|
||||
|
||||
return {
|
||||
update: update => {
|
||||
changeManager.handleUpdate(update)
|
||||
},
|
||||
destroy: () => {
|
||||
changeManager.destroy()
|
||||
},
|
||||
}
|
||||
})
|
||||
: ViewPlugin.define(view => {
|
||||
let timer: number
|
||||
|
||||
return {
|
||||
update(update) {
|
||||
if (update.viewportChanged) {
|
||||
if (timer) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = window.setTimeout(() => {
|
||||
dispatchEvent(new Event('editor:viewport-changed'))
|
||||
}, 25)
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// draw change decorations
|
||||
ViewPlugin.define<
|
||||
PluginValue & {
|
||||
decorations: DecorationSet
|
||||
}
|
||||
>(
|
||||
() => {
|
||||
let decorations = Decoration.none
|
||||
if (isSplitTestEnabled('review-panel-redesign')) {
|
||||
if (ranges && threads) {
|
||||
decorations = buildChangeDecorations(currentDoc, {
|
||||
ranges,
|
||||
threads,
|
||||
})
|
||||
}
|
||||
} else if (!loadingThreads) {
|
||||
decorations = buildChangeDecorations(currentDoc)
|
||||
}
|
||||
|
||||
return {
|
||||
decorations,
|
||||
update(update) {
|
||||
for (const transaction of update.transactions) {
|
||||
this.decorations = this.decorations.map(transaction.changes)
|
||||
|
||||
for (const effect of transaction.effects) {
|
||||
if (effect.is(clearChangesEffect)) {
|
||||
this.decorations = Decoration.none
|
||||
} else if (effect.is(buildChangesEffect)) {
|
||||
this.decorations = buildChangeDecorations(currentDoc)
|
||||
} else if (effect.is(updateRangesEffect)) {
|
||||
this.decorations = buildChangeDecorations(
|
||||
currentDoc,
|
||||
effect.value
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: value => value.decorations,
|
||||
}
|
||||
),
|
||||
|
||||
// styles for change decorations
|
||||
trackChangesTheme,
|
||||
]
|
||||
}
|
||||
|
||||
export const clearChangeMarkers = () => {
|
||||
return {
|
||||
effects: clearChangesEffect.of(null),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildChangeMarkers = () => {
|
||||
return {
|
||||
effects: buildChangesEffect.of(null),
|
||||
}
|
||||
}
|
||||
|
||||
const buildChangeDecorations = (
|
||||
currentDoc: DocumentContainer,
|
||||
data?: RangesData
|
||||
) => {
|
||||
const ranges = data ? data.ranges : currentDoc.ranges
|
||||
|
||||
if (!ranges) {
|
||||
return Decoration.none
|
||||
}
|
||||
|
||||
const changes = [...ranges.changes, ...ranges.comments]
|
||||
|
||||
const decorations = []
|
||||
|
||||
for (const change of changes) {
|
||||
try {
|
||||
decorations.push(...createChangeRange(change, currentDoc, data))
|
||||
} catch (error) {
|
||||
// ignore invalid changes
|
||||
debugConsole.debug('invalid change position', error)
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(decorations, true)
|
||||
}
|
||||
|
||||
class ChangeDeletedWidget extends WidgetType {
|
||||
constructor(public change: Change<DeleteOperation>) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const widget = document.createElement('span')
|
||||
widget.classList.add('ol-cm-change')
|
||||
widget.classList.add('ol-cm-change-d')
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
eq() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class ChangeCalloutWidget extends WidgetType {
|
||||
constructor(
|
||||
public change: Change,
|
||||
public opType: string
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const widget = document.createElement('span')
|
||||
widget.className = 'ol-cm-change-callout'
|
||||
widget.classList.add(`ol-cm-change-callout-${this.opType}`)
|
||||
|
||||
const inner = document.createElement('span')
|
||||
inner.classList.add('ol-cm-change-callout-inner')
|
||||
widget.appendChild(inner)
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
eq(widget: ChangeCalloutWidget) {
|
||||
return widget.opType === this.opType
|
||||
}
|
||||
|
||||
updateDOM(element: HTMLElement) {
|
||||
element.className = 'ol-cm-change-callout'
|
||||
element.classList.add(`ol-cm-change-callout-${this.opType}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const createChangeRange = (
|
||||
change: Change,
|
||||
currentDoc: DocumentContainer,
|
||||
data?: RangesData
|
||||
) => {
|
||||
const { id, metadata, op } = change
|
||||
|
||||
const from = op.p
|
||||
// TODO: find valid positions?
|
||||
|
||||
if (isDeleteOperation(op)) {
|
||||
const opType = 'd'
|
||||
|
||||
const changeWidget = Decoration.widget({
|
||||
widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
|
||||
side: 1,
|
||||
opType,
|
||||
id,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const calloutWidget = Decoration.widget({
|
||||
widget: new ChangeCalloutWidget(change, opType),
|
||||
side: 1,
|
||||
opType,
|
||||
id,
|
||||
metadata,
|
||||
})
|
||||
|
||||
return [calloutWidget.range(from, from), changeWidget.range(from, from)]
|
||||
}
|
||||
|
||||
const _isCommentOperation = isCommentOperation(op)
|
||||
|
||||
if (
|
||||
_isCommentOperation &&
|
||||
(currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
|
||||
.resolvedThreadIds![op.t]
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (_isCommentOperation) {
|
||||
if (data) {
|
||||
const thread = data.threads[op.t]
|
||||
if (!thread || thread.resolved) {
|
||||
return []
|
||||
}
|
||||
} else if (
|
||||
(currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
|
||||
.resolvedThreadIds![op.t]
|
||||
) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const opType = _isCommentOperation ? 'c' : 'i'
|
||||
const changedText = _isCommentOperation ? op.c : op.i
|
||||
const to = from + changedText.length
|
||||
|
||||
// Mark decorations must not be empty
|
||||
if (from === to) {
|
||||
return []
|
||||
}
|
||||
|
||||
const changeMark = Decoration.mark({
|
||||
tagName: 'span',
|
||||
class: `ol-cm-change ol-cm-change-${opType}`,
|
||||
opType,
|
||||
id,
|
||||
metadata,
|
||||
})
|
||||
|
||||
const calloutWidget = Decoration.widget({
|
||||
widget: new ChangeCalloutWidget(change, opType),
|
||||
opType,
|
||||
id,
|
||||
metadata,
|
||||
})
|
||||
|
||||
return [calloutWidget.range(from, from), changeMark.range(from, to)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tracked changes from the range tracker when they're rejected,
|
||||
* and restore the original content
|
||||
*/
|
||||
export const rejectChanges = (
|
||||
state: EditorState,
|
||||
ranges: DocumentContainer['ranges'],
|
||||
changeIds: string[]
|
||||
) => {
|
||||
const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
|
||||
|
||||
if (changes.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// When doing bulk rejections, adjacent changes might interact with each other.
|
||||
// Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
|
||||
//
|
||||
// "foo bar baz" -> "foo quux baz"
|
||||
//
|
||||
// The change above will be modeled with two ops, with the insertion going first:
|
||||
//
|
||||
// foo quux baz
|
||||
// |--| -> insertion of "quux", op 1, at position 4
|
||||
// | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
|
||||
//
|
||||
// When rejecting these changes at once, if the insertion is rejected first, we get unexpected
|
||||
// results. What happens is:
|
||||
//
|
||||
// 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
|
||||
// starting from position 4;
|
||||
//
|
||||
// "foo quux baz" -> "foo baz"
|
||||
// |--| -> 4 characters to be removed
|
||||
//
|
||||
// 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
|
||||
// the word "quuux" was still present).
|
||||
//
|
||||
// "foo baz" -> "foo bazbar"
|
||||
// | -> deletion of "bar" is reverted by reinserting "bar" at position 8
|
||||
//
|
||||
// While the intended result would be "foo bar baz", what we get is:
|
||||
//
|
||||
// "foo bazbar" (note "bar" readded at position 8)
|
||||
//
|
||||
// The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
|
||||
// from position 4. This includes the position where the deletion exists; when that position is
|
||||
// cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
|
||||
// As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
|
||||
// does so at the outdated position (position 8, which was valid when "quux" was present).
|
||||
//
|
||||
// To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
|
||||
// subsequent operations that come after. Reverse sorting the operations based on position will
|
||||
// achieve it; in the case above, it makes sure that the the deletion is reverted first:
|
||||
//
|
||||
// 1) Rejecting the deletion adds the deleted word "bar" at position 8
|
||||
//
|
||||
// "foo quux baz" -> "foo quuxbar baz"
|
||||
// | -> deletion of "bar" is reverted by
|
||||
// reinserting "bar" at position 8
|
||||
//
|
||||
// 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
|
||||
// starting from position 4 and achieves the expected result:
|
||||
//
|
||||
// "foo quuxbar baz" -> "foo bar baz"
|
||||
// |--| -> 4 characters to be removed
|
||||
|
||||
changes.sort((a, b) => b.op.p - a.op.p)
|
||||
|
||||
const changesToDispatch = changes.map(change => {
|
||||
const { op } = change
|
||||
|
||||
if (isInsertOperation(op)) {
|
||||
const from = op.p
|
||||
const content = op.i
|
||||
const to = from + content.length
|
||||
|
||||
const text = state.doc.sliceString(from, to)
|
||||
|
||||
if (text !== content) {
|
||||
throw new Error(`Op to be removed does not match editor text`)
|
||||
}
|
||||
|
||||
return { from, to, insert: '' }
|
||||
} else if (isDeleteOperation(op)) {
|
||||
return {
|
||||
from: op.p,
|
||||
to: op.p,
|
||||
insert: op.d,
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown change type: ${JSON.stringify(change)}`)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
changes: changesToDispatch,
|
||||
annotations: [trackChangesAnnotation.of('reject')],
|
||||
}
|
||||
}
|
||||
|
||||
const trackChangesTheme = EditorView.baseTheme({
|
||||
'.cm-line': {
|
||||
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
|
||||
},
|
||||
'&light .ol-cm-change-i': {
|
||||
backgroundColor: '#2c8e304d',
|
||||
},
|
||||
'&dark .ol-cm-change-i': {
|
||||
backgroundColor: 'rgba(37, 107, 41, 0.15)',
|
||||
},
|
||||
'&light .ol-cm-change-c': {
|
||||
backgroundColor: '#f3b1114d',
|
||||
},
|
||||
'&dark .ol-cm-change-c': {
|
||||
backgroundColor: 'rgba(194, 93, 11, 0.15)',
|
||||
},
|
||||
'.ol-cm-change': {
|
||||
padding: 'var(--half-leading, 0) 0',
|
||||
},
|
||||
'.ol-cm-change-d': {
|
||||
borderLeft: '2px dotted #c5060b',
|
||||
marginLeft: '-1px',
|
||||
},
|
||||
'.ol-cm-change-callout': {
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
padding: 'var(--half-leading, 0) 0',
|
||||
},
|
||||
'.ol-cm-change-callout-inner': {
|
||||
display: 'inline-block',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: '100vw',
|
||||
borderBottom: '1px dashed black',
|
||||
},
|
||||
// disable callout line in Firefox
|
||||
'@supports (-moz-appearance:none)': {
|
||||
'.ol-cm-change-callout-inner': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
'.ol-cm-change-callout-i .ol-cm-change-callout-inner': {
|
||||
borderColor: '#2c8e30',
|
||||
},
|
||||
'.ol-cm-change-callout-c .ol-cm-change-callout-inner': {
|
||||
borderColor: '#f3b111',
|
||||
},
|
||||
'.ol-cm-change-callout-d .ol-cm-change-callout-inner': {
|
||||
borderColor: '#c5060b',
|
||||
},
|
||||
})
|
Loading…
Reference in a new issue