mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Use tracked changes and comments from the snapshot (#26267)
GitOrigin-RevId: c2bf0c9c9a5ab4f8837b8712ca549119a31cf067
This commit is contained in:
@@ -21,6 +21,13 @@ class TrackedChangeList {
|
||||
this._trackedChanges = trackedChanges
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {IterableIterator<TrackedChange>}
|
||||
*/
|
||||
[Symbol.iterator]() {
|
||||
return this._trackedChanges.values()
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {TrackedChangeRawData[]} raw
|
||||
|
||||
@@ -871,6 +871,7 @@ const _ProjectController = {
|
||||
showUpgradePrompt,
|
||||
fixedSizeDocument: true,
|
||||
hasTrackChangesFeature: Features.hasFeature('track-changes'),
|
||||
otMigrationStage: project.overleaf?.history?.otMigrationStage ?? 0,
|
||||
projectTags,
|
||||
isSaas: Features.hasFeature('saas'),
|
||||
shouldLoadHotjar: splitTestAssignments.hotjar?.variant === 'enabled',
|
||||
|
||||
@@ -34,6 +34,7 @@ meta(name="ol-showUpgradePrompt" data-type="boolean" content=showUpgradePrompt)
|
||||
meta(name="ol-showSupport", data-type="boolean" content=showSupport)
|
||||
meta(name="ol-showTemplatesServerPro", data-type="boolean" content=showTemplatesServerPro)
|
||||
meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
|
||||
meta(name="ol-otMigrationStage", data-type="number" content=otMigrationStage)
|
||||
meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
|
||||
meta(name="ol-projectTags" data-type="json" content=projectTags)
|
||||
meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage)
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OError from '@overleaf/o-error'
|
||||
import {
|
||||
HistoryOTShareDoc,
|
||||
ShareLatexOTShareDoc,
|
||||
} from '../../../../../types/share-doc'
|
||||
|
||||
const MAX_PENDING_OP_SIZE = 64
|
||||
|
||||
@@ -121,6 +125,27 @@ export class DocumentContainer extends EventEmitter {
|
||||
this.bindToSocketEvents()
|
||||
}
|
||||
|
||||
get shareDoc() {
|
||||
if (!this.doc) {
|
||||
throw new Error('Missing ShareJSDoc')
|
||||
}
|
||||
if (!this.doc._doc) {
|
||||
throw new Error('Missing ShareJS Doc')
|
||||
}
|
||||
return this.doc._doc as HistoryOTShareDoc | ShareLatexOTShareDoc
|
||||
}
|
||||
|
||||
isHistoryOT() {
|
||||
return this.shareDoc.otType === 'history-ot'
|
||||
}
|
||||
|
||||
get historyOTShareDoc() {
|
||||
if (!this.isHistoryOT()) {
|
||||
throw new Error('shareDoc is not historyOT')
|
||||
}
|
||||
return this.shareDoc as HistoryOTShareDoc
|
||||
}
|
||||
|
||||
attachToCM6(cm6: EditorFacade) {
|
||||
this.cm6 = cm6
|
||||
if (this.doc) {
|
||||
@@ -613,6 +638,9 @@ export class DocumentContainer extends EventEmitter {
|
||||
this.trigger('op:timeout')
|
||||
return this.onError(new Error('op timed out'))
|
||||
})
|
||||
this.doc.on('ranges:dirty', (...args) => {
|
||||
return this.trigger('ranges:dirty', ...args)
|
||||
})
|
||||
|
||||
let docChangedTimeout: number | null = null
|
||||
this.doc.on(
|
||||
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
StringFileData,
|
||||
TrackedChangeList,
|
||||
EditOperationBuilder,
|
||||
CommentList,
|
||||
} from 'overleaf-editor-core'
|
||||
import {
|
||||
StringFileRawData,
|
||||
RawEditOperation,
|
||||
} from 'overleaf-editor-core/lib/types'
|
||||
import { HistoryOTShareDoc } from '../../../../../types/share-doc'
|
||||
|
||||
// All times below are in milliseconds
|
||||
const SINGLE_USER_FLUSH_DELAY = 2000
|
||||
@@ -267,11 +269,26 @@ export class ShareJsDoc extends EventEmitter {
|
||||
processUpdateFromServer(message: Message) {
|
||||
try {
|
||||
if (this.type === 'history-ot' && message.op != null) {
|
||||
const shareDoc = this._doc as HistoryOTShareDoc
|
||||
const trackedChangesBefore = shareDoc.snapshot.getTrackedChanges()
|
||||
const commentsBefore = shareDoc.snapshot.getComments()
|
||||
|
||||
const ops = message.op as RawEditOperation[]
|
||||
this._doc._onMessage({
|
||||
...message,
|
||||
op: ops.map(EditOperationBuilder.fromJSON),
|
||||
})
|
||||
|
||||
if (
|
||||
this.rangesUpdated(
|
||||
trackedChangesBefore,
|
||||
commentsBefore,
|
||||
shareDoc.snapshot.getTrackedChanges(),
|
||||
shareDoc.snapshot.getComments()
|
||||
)
|
||||
) {
|
||||
this.trigger('ranges:dirty')
|
||||
}
|
||||
} else {
|
||||
this._doc._onMessage(message)
|
||||
}
|
||||
@@ -471,6 +488,29 @@ export class ShareJsDoc extends EventEmitter {
|
||||
doc.pendingCallbacks.push(() => {
|
||||
return this.trigger('op:acknowledged', op)
|
||||
})
|
||||
|
||||
// history-ot: submit the op and detect whether tracked changes or comments have updated
|
||||
if (this.type === 'history-ot') {
|
||||
const shareDoc = doc as HistoryOTShareDoc
|
||||
const trackedChangesBefore = shareDoc.snapshot.getTrackedChanges()
|
||||
const commentsBefore = shareDoc.snapshot.getComments()
|
||||
const result = submitOp.call(doc, op, callback)
|
||||
|
||||
if (
|
||||
this.rangesUpdated(
|
||||
trackedChangesBefore,
|
||||
commentsBefore,
|
||||
shareDoc.snapshot.getTrackedChanges(),
|
||||
shareDoc.snapshot.getComments()
|
||||
)
|
||||
) {
|
||||
this.trigger('ranges:dirty')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// non-history-ot: just submit the op
|
||||
return submitOp.call(doc, op, callback)
|
||||
}
|
||||
|
||||
@@ -480,4 +520,31 @@ export class ShareJsDoc extends EventEmitter {
|
||||
return flush.call(doc)
|
||||
}
|
||||
}
|
||||
|
||||
private rangesUpdated(
|
||||
trackedChangesBefore: TrackedChangeList,
|
||||
commentsBefore: CommentList,
|
||||
trackedChangesAfter: TrackedChangeList,
|
||||
commentsAfter: CommentList
|
||||
) {
|
||||
return (
|
||||
// quick length comparison first
|
||||
trackedChangesBefore.length !== trackedChangesAfter.length ||
|
||||
commentsBefore.length !== commentsAfter.length ||
|
||||
// then compare each item by identity
|
||||
this.itemsChanged(
|
||||
trackedChangesBefore.asSorted(),
|
||||
trackedChangesAfter.asSorted()
|
||||
) ||
|
||||
this.itemsChanged(commentsBefore.toArray(), commentsAfter.toArray())
|
||||
)
|
||||
}
|
||||
|
||||
private itemsChanged(before: readonly any[], after: readonly any[]) {
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
if (before[i] !== after[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ export const ReviewPanelChange = memo<{
|
||||
setAccepting(true)
|
||||
try {
|
||||
if (aggregate) {
|
||||
await acceptChanges(change.id, aggregate.id)
|
||||
await acceptChanges(change, aggregate)
|
||||
} else {
|
||||
await acceptChanges(change.id)
|
||||
await acceptChanges(change)
|
||||
}
|
||||
} catch (err) {
|
||||
showGenericMessageModal(
|
||||
@@ -69,13 +69,13 @@ export const ReviewPanelChange = memo<{
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}, [acceptChanges, aggregate, change.id, showGenericMessageModal, t])
|
||||
}, [acceptChanges, aggregate, change, showGenericMessageModal, t])
|
||||
|
||||
const rejectHandler = useCallback(async () => {
|
||||
if (aggregate) {
|
||||
await rejectChanges(change.id, aggregate.id)
|
||||
await rejectChanges(change, aggregate)
|
||||
} else {
|
||||
await rejectChanges(change.id)
|
||||
await rejectChanges(change)
|
||||
}
|
||||
}, [aggregate, change, rejectChanges])
|
||||
|
||||
|
||||
@@ -94,7 +94,11 @@ const ReviewPanelCurrentFile: FC = () => {
|
||||
|
||||
if (threads) {
|
||||
for (const comment of ranges.comments) {
|
||||
if (threads[comment.op.t] && !threads[comment.op.t]?.resolved) {
|
||||
if (
|
||||
!comment.resolved &&
|
||||
threads[comment.op.t] &&
|
||||
!threads[comment.op.t]?.resolved
|
||||
) {
|
||||
output.comments.push(comment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ export const ReviewPanelOverviewFile: FC<{
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const unresolvedComments = ranges.comments.filter(comment => {
|
||||
if (comment.resolved) {
|
||||
return false
|
||||
}
|
||||
const thread = threads?.[comment.op.t]
|
||||
return thread && thread.messages.length > 0 && !thread.resolved
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ReviewPanelOverviewFile } from './review-panel-overview-file'
|
||||
import ReviewPanelEmptyState from './review-panel-empty-state'
|
||||
import useProjectRanges from '../hooks/use-project-ranges'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export const ReviewPanelOverview: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -16,12 +17,13 @@ export const ReviewPanelOverview: FC = () => {
|
||||
const rangesForDocs = useMemo(() => {
|
||||
if (docs && docRanges && projectRanges) {
|
||||
const rangesForDocs = new Map<string, Ranges>()
|
||||
const otMigrationStage = getMeta('ol-otMigrationStage')
|
||||
|
||||
for (const doc of docs) {
|
||||
const ranges =
|
||||
doc.doc.id === docRanges.docId
|
||||
? docRanges
|
||||
: projectRanges.get(doc.doc.id)
|
||||
: projectRanges.get(otMigrationStage === 1 ? doc.path : doc.doc.id)
|
||||
|
||||
if (ranges) {
|
||||
rangesForDocs.set(doc.doc.id, ranges)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Change, CommentOperation } from '../../../../../types/change'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import OLBadge from '@/shared/components/ol/ol-badge'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -18,9 +19,12 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||
|
||||
const docNameForThread = useMemo(() => {
|
||||
const docNameForThread = new Map<string, string>()
|
||||
const otMigrationStage = getMeta('ol-otMigrationStage')
|
||||
|
||||
for (const [docId, ranges] of projectRanges?.entries() ?? []) {
|
||||
const docName = docs?.find(doc => doc.doc.id === docId)?.doc.name
|
||||
const docName = docs?.find(
|
||||
doc => (otMigrationStage === 1 ? doc.path : doc.doc.id) === docId
|
||||
)?.doc.name
|
||||
if (docName !== undefined) {
|
||||
for (const comment of ranges.comments) {
|
||||
const threadId = comment.op.t
|
||||
@@ -33,7 +37,10 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||
}, [docs, projectRanges])
|
||||
|
||||
const allComments = useMemo(() => {
|
||||
const allComments = new Map<string, Change<CommentOperation>>()
|
||||
const allComments = new Map<
|
||||
string,
|
||||
Change<CommentOperation> & { resolved?: boolean }
|
||||
>()
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
for (const [_, ranges] of projectRanges?.entries() ?? []) {
|
||||
@@ -52,11 +59,16 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
|
||||
|
||||
const allResolvedThreads = []
|
||||
for (const [id, thread] of Object.entries(threads)) {
|
||||
if (thread.resolved) {
|
||||
// sharejs-text-ot has "resolved" on the thread; history-ot has "resolved" on the comment
|
||||
if (thread.resolved || allComments.get(id)?.resolved) {
|
||||
allResolvedThreads.push({ thread, id })
|
||||
}
|
||||
}
|
||||
allResolvedThreads.sort((a, b) => {
|
||||
// TODO: add "resolved_at"/"resolved_by" to history-ot comments?
|
||||
if (!a.thread.resolved_at || !b.thread.resolved_at) {
|
||||
return 0
|
||||
}
|
||||
return Date.parse(b.thread.resolved_at) - Date.parse(a.thread.resolved_at)
|
||||
})
|
||||
|
||||
|
||||
@@ -108,16 +108,14 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
|
||||
const [tooltipStyle, setTooltipStyle] = useState<CSSProperties | undefined>()
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const changeIdsInSelection = useMemo(() => {
|
||||
return (ranges?.changes ?? [])
|
||||
.filter(({ op }) => {
|
||||
const opFrom = op.p
|
||||
const opLength = isInsertOperation(op) ? op.i.length : 0
|
||||
const opTo = opFrom + opLength
|
||||
const selection = state.selection.main
|
||||
return opFrom >= selection.from && opTo <= selection.to
|
||||
})
|
||||
.map(({ id }) => id)
|
||||
const changesInSelection = useMemo(() => {
|
||||
return (ranges?.changes ?? []).filter(({ op }) => {
|
||||
const opFrom = op.p
|
||||
const opLength = isInsertOperation(op) ? op.i.length : 0
|
||||
const opTo = opFrom + opLength
|
||||
const selection = state.selection.main
|
||||
return opFrom >= selection.from && opTo <= selection.to
|
||||
})
|
||||
}, [ranges, state.selection.main])
|
||||
|
||||
const acceptChangesHandler = useCallback(() => {
|
||||
@@ -129,13 +127,13 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
|
||||
message: t('confirm_accept_selected_changes', { count: nChanges }),
|
||||
title: t('accept_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
await acceptChanges(...changeIdsInSelection)
|
||||
await acceptChanges(...changesInSelection)
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
}, [
|
||||
acceptChanges,
|
||||
changeIdsInSelection,
|
||||
changesInSelection,
|
||||
ranges,
|
||||
showGenericConfirmModal,
|
||||
view,
|
||||
@@ -151,7 +149,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
|
||||
message: t('confirm_reject_selected_changes', { count: nChanges }),
|
||||
title: t('reject_selected_changes'),
|
||||
onConfirm: async () => {
|
||||
await rejectChanges(...changeIdsInSelection)
|
||||
await rejectChanges(...changesInSelection)
|
||||
},
|
||||
primaryVariant: 'danger',
|
||||
})
|
||||
@@ -161,10 +159,10 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
|
||||
ranges,
|
||||
view,
|
||||
rejectChanges,
|
||||
changeIdsInSelection,
|
||||
changesInSelection,
|
||||
])
|
||||
|
||||
const showChangesButtons = changeIdsInSelection.length > 0
|
||||
const showChangesButtons = changesInSelection.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
view.requestMeasure({
|
||||
|
||||
@@ -22,18 +22,34 @@ import { useConnectionContext } from '@/features/ide-react/context/connection-co
|
||||
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
|
||||
import { throttle } from 'lodash'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { TextOperation, Range } from 'overleaf-editor-core'
|
||||
import { rangesUpdatedEffect } from '@/features/source-editor/extensions/history-ot'
|
||||
import ClearTrackingProps from 'overleaf-editor-core/lib/file_data/clear_tracking_props'
|
||||
import { isInsertOperation } from '@/utils/operations'
|
||||
import {
|
||||
EditorSelection,
|
||||
Transaction,
|
||||
TransactionSpec,
|
||||
} from '@codemirror/state'
|
||||
import { buildRangesFromSnapshot } from '@/features/review-panel/utils/snapshot-ranges'
|
||||
|
||||
export type Ranges = {
|
||||
docId: string
|
||||
changes: Change<EditOperation>[]
|
||||
comments: Change<CommentOperation>[]
|
||||
changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
|
||||
comments: Array<
|
||||
Change<CommentOperation> & { snapshotRange?: Range; resolved?: boolean }
|
||||
>
|
||||
}
|
||||
|
||||
export const RangesContext = createContext<Ranges | undefined>(undefined)
|
||||
|
||||
type RangesActions = {
|
||||
acceptChanges: (...ids: string[]) => Promise<void>
|
||||
rejectChanges: (...ids: string[]) => Promise<void>
|
||||
acceptChanges: (
|
||||
...changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
|
||||
) => Promise<void>
|
||||
rejectChanges: (
|
||||
...changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
const buildRanges = (currentDocument: DocumentContainer | null) => {
|
||||
@@ -76,6 +92,13 @@ const buildRanges = (currentDocument: DocumentContainer | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildRangesFromHistoryOT = (currentDocument: DocumentContainer) => {
|
||||
return buildRangesFromSnapshot(
|
||||
currentDocument.historyOTShareDoc.snapshot,
|
||||
currentDocument.doc_id
|
||||
)
|
||||
}
|
||||
|
||||
const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
|
||||
|
||||
export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
@@ -89,11 +112,37 @@ export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
|
||||
// rebuild the ranges when the current doc changes
|
||||
useEffect(() => {
|
||||
setRanges(buildRanges(currentDocument))
|
||||
if (currentDocument) {
|
||||
if (currentDocument.isHistoryOT()) {
|
||||
setRanges(buildRangesFromHistoryOT(currentDocument))
|
||||
} else {
|
||||
setRanges(buildRanges(currentDocument))
|
||||
}
|
||||
}
|
||||
}, [currentDocument])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDocument) {
|
||||
if (currentDocument && currentDocument.isHistoryOT()) {
|
||||
const listener = throttle(
|
||||
() => {
|
||||
window.setTimeout(() => {
|
||||
setRanges(buildRangesFromHistoryOT(currentDocument))
|
||||
})
|
||||
},
|
||||
500,
|
||||
{ leading: true, trailing: true }
|
||||
)
|
||||
|
||||
currentDocument.on('ranges:dirty.ot', listener)
|
||||
|
||||
return () => {
|
||||
currentDocument.off('ranges:dirty.ot')
|
||||
}
|
||||
}
|
||||
}, [currentDocument])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDocument && !currentDocument.isHistoryOT()) {
|
||||
const listener = throttle(
|
||||
() => {
|
||||
window.setTimeout(() => {
|
||||
@@ -156,24 +205,151 @@ export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
)
|
||||
)
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
async acceptChanges(...ids: string[]) {
|
||||
if (currentDocument?.ranges) {
|
||||
const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
|
||||
await postJSON(url, { body: { change_ids: ids } })
|
||||
currentDocument.ranges.removeChangeIds(ids)
|
||||
setRanges(buildRanges(currentDocument))
|
||||
}
|
||||
},
|
||||
async rejectChanges(...ids: string[]) {
|
||||
if (currentDocument?.ranges) {
|
||||
view.dispatch(rejectChanges(view.state, currentDocument.ranges, ids))
|
||||
}
|
||||
},
|
||||
}),
|
||||
[currentDocument, projectId, view]
|
||||
)
|
||||
const actions = useMemo(() => {
|
||||
if (!currentDocument) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentDocument.isHistoryOT()) {
|
||||
return {
|
||||
async acceptChanges(...changes) {
|
||||
const op = new TextOperation()
|
||||
|
||||
let currentSnapshotPos = 0
|
||||
for (const change of changes) {
|
||||
const { start, end, length } = change.snapshotRange!
|
||||
|
||||
if (start > currentSnapshotPos) {
|
||||
op.retain(start - currentSnapshotPos)
|
||||
}
|
||||
|
||||
currentSnapshotPos = end
|
||||
|
||||
if (isInsertOperation(change.op)) {
|
||||
// accept tracked insertion
|
||||
|
||||
// clear tracking from snapshot
|
||||
op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
|
||||
} else {
|
||||
// accept tracked deletion
|
||||
|
||||
// remove text from snapshot
|
||||
op.remove(length) // NOTE: tracking is removed automatically
|
||||
}
|
||||
}
|
||||
|
||||
const shareDoc = currentDocument.historyOTShareDoc
|
||||
|
||||
const length = shareDoc.snapshot.getStringLength()
|
||||
|
||||
if (currentSnapshotPos < length) {
|
||||
op.retain(length - currentSnapshotPos)
|
||||
}
|
||||
|
||||
shareDoc.submitOp([op])
|
||||
|
||||
// dispatch an effect as the editor's doc doesn't change when tracked changes are accepted
|
||||
view.dispatch({
|
||||
effects: rangesUpdatedEffect.of(null),
|
||||
})
|
||||
},
|
||||
async rejectChanges(...changes) {
|
||||
const shareDoc = currentDocument.historyOTShareDoc
|
||||
|
||||
const originalLength = shareDoc.snapshot.getStringLength()
|
||||
|
||||
const op = new TextOperation()
|
||||
|
||||
let currentSnapshotPos = 0
|
||||
const specs: TransactionSpec[] = []
|
||||
for (const change of changes) {
|
||||
const { start, end, length } = change.snapshotRange!
|
||||
|
||||
if (start > currentSnapshotPos) {
|
||||
op.retain(start - currentSnapshotPos)
|
||||
}
|
||||
|
||||
currentSnapshotPos = end
|
||||
|
||||
if (isInsertOperation(change.op)) {
|
||||
// reject tracked insertion
|
||||
|
||||
// remove text from snapshot
|
||||
op.remove(length) // NOTE: tracking is removed automatically
|
||||
|
||||
// remove text from editor
|
||||
specs.push({
|
||||
changes: {
|
||||
from: change.op.p,
|
||||
to: change.op.p + change.op.i.length,
|
||||
insert: '',
|
||||
},
|
||||
annotations: [
|
||||
Transaction.remote.of(true),
|
||||
// Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// reject tracked deletion
|
||||
|
||||
// remove tracking from snapshot
|
||||
op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
|
||||
|
||||
// re-add text to editor
|
||||
specs.push({
|
||||
changes: {
|
||||
from: change.op.p,
|
||||
insert: change.op.d,
|
||||
},
|
||||
selection: EditorSelection.cursor(
|
||||
change.op.p + change.op.d.length
|
||||
), // TODO: map selection through changes?
|
||||
annotations: [
|
||||
Transaction.remote.of(true),
|
||||
// Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSnapshotPos < originalLength) {
|
||||
op.retain(originalLength - currentSnapshotPos)
|
||||
}
|
||||
|
||||
shareDoc.submitOp([op])
|
||||
|
||||
// in case the doc didn't change
|
||||
view.dispatch(...specs, {
|
||||
effects: rangesUpdatedEffect.of(null),
|
||||
})
|
||||
},
|
||||
} satisfies RangesActions
|
||||
} else {
|
||||
return {
|
||||
async acceptChanges(...changes) {
|
||||
if (currentDocument.ranges) {
|
||||
const ids = changes.map(change => change.id)
|
||||
const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
|
||||
await postJSON(url, { body: { change_ids: ids } })
|
||||
currentDocument.ranges.removeChangeIds(ids)
|
||||
setRanges(buildRanges(currentDocument))
|
||||
}
|
||||
},
|
||||
async rejectChanges(...changes) {
|
||||
if (currentDocument.ranges) {
|
||||
const ids = changes.map(change => change.id)
|
||||
view.dispatch(
|
||||
rejectChanges(view.state, currentDocument.ranges, ids)
|
||||
)
|
||||
}
|
||||
},
|
||||
} satisfies RangesActions
|
||||
}
|
||||
}, [currentDocument, projectId, view])
|
||||
|
||||
if (!actions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RangesActionsContext.Provider value={actions}>
|
||||
|
||||
@@ -24,6 +24,15 @@ import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-ope
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { captureException } from '@/infrastructure/error-reporter'
|
||||
import {
|
||||
AddCommentOperation,
|
||||
DeleteCommentOperation,
|
||||
SetCommentStateOperation,
|
||||
} from 'overleaf-editor-core'
|
||||
import Range from 'overleaf-editor-core/lib/range'
|
||||
import { trackedDeletesFromState } from '@/features/source-editor/utils/tracked-deletes'
|
||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
||||
import { rangesUpdatedEffect } from '@/features/source-editor/extensions/history-ot'
|
||||
|
||||
export type Threads = Record<ThreadId, ReviewPanelCommentThread>
|
||||
|
||||
@@ -52,10 +61,13 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const { projectId } = useProjectContext()
|
||||
const { currentDocument } = useEditorOpenDocContext()
|
||||
const { isRestrictedTokenMember } = useEditorContext()
|
||||
const view = useCodeMirrorViewContext()
|
||||
|
||||
// const [error, setError] = useState<Error>()
|
||||
const [data, setData] = useState<Threads>()
|
||||
|
||||
const isHistoryOT = currentDocument?.isHistoryOT()
|
||||
|
||||
// load the initial threads data
|
||||
useEffect(() => {
|
||||
if (isRestrictedTokenMember) {
|
||||
@@ -250,8 +262,12 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
}, [])
|
||||
)
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
const actions = useMemo(() => {
|
||||
if (!currentDocument) {
|
||||
return
|
||||
}
|
||||
|
||||
const actions = {
|
||||
async addComment(pos: number, text: string, content: string) {
|
||||
const threadId = RangesTracker.generateId() as ThreadId
|
||||
|
||||
@@ -265,23 +281,23 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
t: threadId,
|
||||
}
|
||||
|
||||
currentDocument?.submitOp(op)
|
||||
currentDocument.submitOp(op)
|
||||
},
|
||||
async resolveThread(threadId: string) {
|
||||
await postJSON(
|
||||
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/resolve`
|
||||
`/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/resolve`
|
||||
)
|
||||
},
|
||||
async reopenThread(threadId: string) {
|
||||
await postJSON(
|
||||
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/reopen`
|
||||
`/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/reopen`
|
||||
)
|
||||
},
|
||||
async deleteThread(threadId: string) {
|
||||
await deleteJSON(
|
||||
`/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}`
|
||||
`/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}`
|
||||
)
|
||||
currentDocument?.ranges?.removeCommentId(threadId)
|
||||
currentDocument.ranges?.removeCommentId(threadId)
|
||||
},
|
||||
async addMessage(threadId: ThreadId, content: string) {
|
||||
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
|
||||
@@ -308,9 +324,57 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
`/project/${projectId}/thread/${threadId}/own-messages/${commentId}`
|
||||
)
|
||||
},
|
||||
}),
|
||||
[currentDocument, projectId]
|
||||
)
|
||||
}
|
||||
|
||||
if (isHistoryOT) {
|
||||
// TODO: dispatch on view instead?
|
||||
Object.assign(actions, {
|
||||
async addComment(pos: number, text: string, content: string) {
|
||||
const threadId = RangesTracker.generateId() as ThreadId // TODO
|
||||
|
||||
await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
|
||||
body: { content },
|
||||
})
|
||||
|
||||
const trackedDeletes = trackedDeletesFromState(view.state)
|
||||
pos = trackedDeletes.toSnapshot(pos)
|
||||
const ranges = [new Range(pos, text.length)]
|
||||
const op = new AddCommentOperation(threadId, ranges)
|
||||
currentDocument.historyOTShareDoc.submitOp([op])
|
||||
view.dispatch({
|
||||
effects: rangesUpdatedEffect.of(null),
|
||||
})
|
||||
},
|
||||
async resolveThread(threadId: string) {
|
||||
const op = new SetCommentStateOperation(threadId, true)
|
||||
currentDocument.historyOTShareDoc.submitOp([op])
|
||||
view.dispatch({
|
||||
effects: rangesUpdatedEffect.of(null),
|
||||
})
|
||||
},
|
||||
async reopenThread(threadId: string) {
|
||||
const op = new SetCommentStateOperation(threadId, false)
|
||||
currentDocument.historyOTShareDoc.submitOp([op])
|
||||
view.dispatch({
|
||||
effects: rangesUpdatedEffect.of(null),
|
||||
})
|
||||
},
|
||||
async deleteThread(threadId: string) {
|
||||
const op = new DeleteCommentOperation(threadId)
|
||||
currentDocument.historyOTShareDoc.submitOp([op])
|
||||
view.dispatch({
|
||||
effects: rangesUpdatedEffect.of(null),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
}, [view, currentDocument, projectId, isHistoryOT])
|
||||
|
||||
if (!actions) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadsActionsContext.Provider value={actions}>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
|
||||
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { buildProjectRangesFromSnapshot } from '@/features/review-panel/utils/snapshot-ranges'
|
||||
|
||||
export default function useProjectRanges() {
|
||||
const { projectId } = useProjectContext()
|
||||
@@ -11,27 +13,36 @@ export default function useProjectRanges() {
|
||||
const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { socket } = useConnectionContext()
|
||||
const otMigrationStage = getMeta('ol-otMigrationStage')
|
||||
const { projectSnapshot } = useProjectContext()
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
|
||||
.then(data => {
|
||||
setProjectRanges(
|
||||
new Map(
|
||||
data.map(item => [
|
||||
item.id,
|
||||
{
|
||||
docId: item.id,
|
||||
changes: item.ranges.changes ?? [],
|
||||
comments: item.ranges.comments ?? [],
|
||||
},
|
||||
])
|
||||
)
|
||||
)
|
||||
if (otMigrationStage === 1) {
|
||||
projectSnapshot.refresh().then(() => {
|
||||
setProjectRanges(buildProjectRangesFromSnapshot(projectSnapshot))
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
} else {
|
||||
setLoading(true)
|
||||
getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
|
||||
.then(data => {
|
||||
setProjectRanges(
|
||||
new Map(
|
||||
data.map(item => [
|
||||
item.id,
|
||||
{
|
||||
docId: item.id,
|
||||
changes: item.ranges.changes ?? [],
|
||||
comments: item.ranges.comments ?? [],
|
||||
},
|
||||
])
|
||||
)
|
||||
)
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [projectId, otMigrationStage, projectSnapshot])
|
||||
|
||||
useSocketListener(
|
||||
socket,
|
||||
@@ -60,5 +71,18 @@ export default function useProjectRanges() {
|
||||
}, [])
|
||||
)
|
||||
|
||||
useSocketListener(
|
||||
socket,
|
||||
'new-comment',
|
||||
useCallback(() => {
|
||||
if (otMigrationStage === 1) {
|
||||
projectSnapshot.refresh().then(() => {
|
||||
setProjectRanges(buildProjectRangesFromSnapshot(projectSnapshot))
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
}, [otMigrationStage, projectSnapshot])
|
||||
)
|
||||
|
||||
return { projectRanges, error, loading }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TrackedDeletes } from '@/features/source-editor/utils/tracked-deletes'
|
||||
import { UserId } from '../../../../../types/user'
|
||||
import { ThreadId } from '../../../../../types/review-panel/review-panel'
|
||||
import { Ranges } from '@/features/review-panel/context/ranges-context'
|
||||
import { StringFileData } from 'overleaf-editor-core'
|
||||
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
|
||||
|
||||
export const buildProjectRangesFromSnapshot = (
|
||||
projectSnapshot: ProjectSnapshot
|
||||
) => {
|
||||
const projectRanges = new Map()
|
||||
for (const [path, file] of projectSnapshot.getDocs().entries()) {
|
||||
const ranges = buildRangesFromSnapshot(file.data as StringFileData, path)
|
||||
projectRanges.set(path, ranges)
|
||||
}
|
||||
return projectRanges
|
||||
}
|
||||
|
||||
export const buildRangesFromSnapshot = (
|
||||
snapshot: StringFileData,
|
||||
docId: string
|
||||
) => {
|
||||
const comments = snapshot.getComments()
|
||||
const trackedChanges = snapshot.getTrackedChanges()
|
||||
const snapshotContent = snapshot.getContent()
|
||||
|
||||
const trackedDeletes = new TrackedDeletes(trackedChanges)
|
||||
|
||||
const ranges: Ranges = {
|
||||
docId,
|
||||
changes: [], // TODO: trackedChanges, once React components are updated
|
||||
comments: [], // TODO: comments, once React components are updated
|
||||
}
|
||||
|
||||
for (const trackedChange of trackedChanges) {
|
||||
const { range, tracking } = trackedChange
|
||||
const text = snapshotContent.substring(range.pos, range.end)
|
||||
const pos = trackedDeletes.toCodeMirror(range.pos)
|
||||
const id = `change-${tracking.type}-${pos}`
|
||||
|
||||
const metadata = {
|
||||
user_id: tracking.userId as UserId,
|
||||
ts: tracking.ts,
|
||||
}
|
||||
|
||||
const op =
|
||||
tracking.type === 'insert' ? { p: pos, i: text } : { p: pos, d: text }
|
||||
ranges.changes.push({ id, metadata, op, snapshotRange: range })
|
||||
}
|
||||
|
||||
const seenComments = new Set<string>()
|
||||
for (const comment of comments) {
|
||||
if (!seenComments.has(comment.id)) {
|
||||
seenComments.add(comment.id)
|
||||
|
||||
const range = comment.ranges[0] // show the comment next to the first range
|
||||
const pos = trackedDeletes.toCodeMirror(range.pos)
|
||||
const text = snapshotContent.substring(pos, range.end)
|
||||
|
||||
ranges.comments.push({
|
||||
id: comment.id,
|
||||
op: {
|
||||
p: pos,
|
||||
c: text,
|
||||
t: comment.id as ThreadId,
|
||||
},
|
||||
snapshotRange: range,
|
||||
resolved: comment.resolved,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,8 @@ import {
|
||||
RemoveOp,
|
||||
RetainOp,
|
||||
} from 'overleaf-editor-core'
|
||||
import {
|
||||
updateTrackedChangesEffect,
|
||||
setTrackChangesUserId,
|
||||
trackedChangesState,
|
||||
shareDocState,
|
||||
} from './history-ot'
|
||||
import { rangesUpdatedEffect, setTrackChangesUserId } from './history-ot'
|
||||
import { trackedDeletesFromState } from '@/features/source-editor/utils/tracked-deletes'
|
||||
|
||||
/*
|
||||
* Integrate CodeMirror 6 with the real-time system, via ShareJS.
|
||||
@@ -355,8 +351,7 @@ class HistoryOTAdapter {
|
||||
}
|
||||
|
||||
onRemoteOp(operations: EditOperation[]) {
|
||||
const positionMapper =
|
||||
this.editor.view.state.field(trackedChangesState).positionMapper
|
||||
const trackedDeletes = trackedDeletesFromState(this.editor.view.state)
|
||||
const changes: ChangeSpec[] = []
|
||||
let trackedChangesUpdated = false
|
||||
for (const operation of operations) {
|
||||
@@ -366,15 +361,15 @@ class HistoryOTAdapter {
|
||||
if (op instanceof InsertOp) {
|
||||
if (op.tracking?.type !== 'delete') {
|
||||
changes.push({
|
||||
from: positionMapper.toCM6(cursor),
|
||||
from: trackedDeletes.toCodeMirror(cursor),
|
||||
insert: op.insertion,
|
||||
})
|
||||
}
|
||||
trackedChangesUpdated = true
|
||||
} else if (op instanceof RemoveOp) {
|
||||
changes.push({
|
||||
from: positionMapper.toCM6(cursor),
|
||||
to: positionMapper.toCM6(cursor + op.length),
|
||||
from: trackedDeletes.toCodeMirror(cursor),
|
||||
to: trackedDeletes.toCodeMirror(cursor + op.length),
|
||||
})
|
||||
cursor += op.length
|
||||
trackedChangesUpdated = true
|
||||
@@ -382,8 +377,8 @@ class HistoryOTAdapter {
|
||||
if (op.tracking != null) {
|
||||
if (op.tracking.type === 'delete') {
|
||||
changes.push({
|
||||
from: positionMapper.toCM6(cursor),
|
||||
to: positionMapper.toCM6(cursor + op.length),
|
||||
from: trackedDeletes.toCodeMirror(cursor),
|
||||
to: trackedDeletes.toCodeMirror(cursor + op.length),
|
||||
})
|
||||
}
|
||||
trackedChangesUpdated = true
|
||||
@@ -402,11 +397,7 @@ class HistoryOTAdapter {
|
||||
effects.push(scrollEffect)
|
||||
}
|
||||
if (trackedChangesUpdated) {
|
||||
const shareDoc = this.editor.view.state.field(shareDocState)
|
||||
if (shareDoc != null) {
|
||||
const trackedChanges = shareDoc.snapshot.getTrackedChanges()
|
||||
effects.push(updateTrackedChangesEffect.of(trackedChanges))
|
||||
}
|
||||
effects.push(rangesUpdatedEffect.of(null))
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TrackedChangeList } from 'overleaf-editor-core'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { rangesState } from '@/features/source-editor/extensions/history-ot'
|
||||
|
||||
export const trackedDeletesFromState = (state: EditorState) =>
|
||||
new TrackedDeletes(state.field(rangesState).trackedChanges)
|
||||
|
||||
type OffsetTable = { pos: number; map: (pos: number) => number }[]
|
||||
|
||||
export class TrackedDeletes {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toCodeMirror(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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import pLimit from 'p-limit'
|
||||
import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
|
||||
import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core'
|
||||
import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
|
||||
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
@@ -108,6 +108,17 @@ export class ProjectSnapshot {
|
||||
return await this.blobStore.getString(hash, options)
|
||||
}
|
||||
|
||||
getDocs(): Map<string, File> {
|
||||
const files = new Map()
|
||||
for (const path of this.snapshot.getFilePathnames()) {
|
||||
const file = this.snapshot.getFile(path)
|
||||
if (file?.isEditable()) {
|
||||
files.set(path, file)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start a refresh
|
||||
*/
|
||||
|
||||
@@ -194,6 +194,7 @@ export interface Meta {
|
||||
'ol-notificationsInstitution': InstitutionType[]
|
||||
'ol-oauthProviders': OAuthProviders
|
||||
'ol-odcData': OnboardingFormData
|
||||
'ol-otMigrationStage': number
|
||||
'ol-overallThemes': OverallThemeMeta[]
|
||||
'ol-pages': number
|
||||
'ol-passwordStrengthOptions': PasswordStrengthOptions
|
||||
|
||||
@@ -115,5 +115,6 @@ export const mockDoc = (
|
||||
getPendingOp: () => null,
|
||||
hasBufferedOps: () => false,
|
||||
leaveAndCleanUpPromise: () => false,
|
||||
isHistoryOT: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user