mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
8668fd3a15
GitOrigin-RevId: 1f059eab4e0a85909a1bfecf22d07e243dfeba39
361 lines
8.9 KiB
TypeScript
361 lines
8.9 KiB
TypeScript
import {
|
|
EditorState,
|
|
RangeSet,
|
|
StateEffect,
|
|
StateField,
|
|
Transaction,
|
|
} 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 { CurrentDoc } from '../../../../../types/current-doc'
|
|
import {
|
|
Change,
|
|
ChangeOperation,
|
|
CommentOperation,
|
|
DeleteOperation,
|
|
Operation,
|
|
} from '../../../../../types/change'
|
|
import { ChangeManager } from './changes/change-manager'
|
|
|
|
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: CurrentDoc
|
|
loadingThreads: boolean
|
|
}
|
|
|
|
/**
|
|
* 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 }: 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
|
|
ViewPlugin.define(() => {
|
|
changeManager.initialize()
|
|
|
|
return {
|
|
update: update => {
|
|
changeManager.handleUpdate(update)
|
|
},
|
|
destroy: () => {
|
|
changeManager.destroy()
|
|
},
|
|
}
|
|
}),
|
|
|
|
// draw change decorations
|
|
ViewPlugin.define<
|
|
PluginValue & {
|
|
decorations: DecorationSet
|
|
}
|
|
>(
|
|
() => {
|
|
return {
|
|
decorations: loadingThreads
|
|
? Decoration.none
|
|
: buildChangeDecorations(currentDoc),
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
}
|
|
},
|
|
{
|
|
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: CurrentDoc) => {
|
|
const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
|
|
|
|
const decorations = []
|
|
|
|
for (const change of changes) {
|
|
try {
|
|
decorations.push(...createChangeRange(change, currentDoc))
|
|
} catch (error) {
|
|
// ignore invalid changes
|
|
console.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 isInsertOperation = (op: Operation): op is InsertOperation => 'i' in op
|
|
const isChangeOperation = (op: Operation): op is ChangeOperation =>
|
|
'c' in op && 't' in op
|
|
const isCommentOperation = (op: Operation): op is CommentOperation =>
|
|
'c' in op && !('t' in op)
|
|
const isDeleteOperation = (op: Operation): op is DeleteOperation => 'd' in op
|
|
|
|
const createChangeRange = (change: Change, currentDoc: CurrentDoc) => {
|
|
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)]
|
|
}
|
|
|
|
if (isChangeOperation(op) && currentDoc.ranges.resolvedThreadIds[op.t]) {
|
|
return []
|
|
}
|
|
|
|
const isChangeOrCommentOperation =
|
|
isChangeOperation(op) || isCommentOperation(op)
|
|
const opType = isChangeOrCommentOperation ? 'c' : 'i'
|
|
const changedText = isChangeOrCommentOperation ? 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)]
|
|
}
|
|
|
|
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: '10000px',
|
|
borderBottom: '1px dashed black',
|
|
},
|
|
'.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',
|
|
},
|
|
})
|