overleaf/services/web/frontend/js/features/source-editor/extensions/track-changes.ts

285 lines
6.7 KiB
TypeScript
Raw Normal View History

import { StateEffect } from '@codemirror/state'
import {
Decoration,
type DecorationSet,
EditorView,
type PluginValue,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { Change, DeleteOperation } from '../../../../../types/change'
import { ChangeManager } from './changes/change-manager'
import { debugConsole } from '@/utils/debugging'
import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
import {
DocumentContainer,
RangesTrackerWithResolvedThreadIds,
} from '@/features/ide-react/editor/document-container'
Add review panel context providers and components (#19490) * Tidy up review panel components * Add ReviewPanel providers * [web] new design for review panel track change (#19544) * [web] new design for review panel track change * fixed mini view * mini icon style change * fix icon size * format date * useRangesUserContext hook * remove useRangesUserContext hook * using full class names * fix action icons hover * change wording for tooltips * added ReviewPanelChangeUser component * Update header in new review panel * Extract ReviewPanelTrackChangesMenuButton as a separate component * Remove wrapper div * Replace h2 with div for review panel label * Rename ReviewPanelTools to ReviewPanelHeader * Rename trackChangesExpanded -> trackChangesMenuExpanded * Dont break memoisation of ReviewPanelTrackChangesMenuButton * Fix the width of the track changes arrow icon * Update how prop types are declared * Remove new empty state from old review panel * Add empty state to new review panel * Add project members and owner to ChangesUsers context (#19624) --------- Co-authored-by: Alf Eaton <alf.eaton@overleaf.com> * Redesign comment entry in review panel (#19678) * Redesign comment entry in review panel * ReviewPanelCommentOptions component * remove unused prop * Tidying * Add conditional import * Optional changeManager * Add more split test compatibility * More split test compatibility * Fixes * Improve overview scrolling * Fix overview scrolling * Fix & simplify track changes toggle * Fix overview scrolling * Fix current file container * ExpandableContent component for messages in review panel (#19738) * ExpandableContent component for messages in review panel * remove isExpanded dependancy * Delete comment option for new review panel (#19772) * Delete comment option for new review panel * dont show thread warning if there are no replies * fix hasReplies issue * Implement initial collapsing overview files * Fix positioning of overview panel * Small styling changes * Add count of unresolved comments and tracked chanegs * More style adjustments * Move review-panel-overview styles into css file * Remove unused var --------- Co-authored-by: Domagoj Kriskovic <dom.kriskovic@overleaf.com> Co-authored-by: David Powell <david.powell@overleaf.com> Co-authored-by: David <33458145+davidmcpowell@users.noreply.github.com> GitOrigin-RevId: e67463443d541f88445a86eed5e2b6ec6040f9c7
2024-08-12 05:50:54 -04:00
const clearChangesEffect = StateEffect.define()
const buildChangesEffect = StateEffect.define()
type Options = {
currentDoc: DocumentContainer
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
) => {
return [
// 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: DocumentContainer) => {
if (!currentDoc.ranges) {
return Decoration.none
}
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
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) => {
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 []
}
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)]
}
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',
},
})