Use tracked changes and comments from the snapshot (#26267)

GitOrigin-RevId: c2bf0c9c9a5ab4f8837b8712ca549119a31cf067
This commit is contained in:
Alf Eaton
2025-09-04 13:12:46 +01:00
committed by Copybot
parent 19866f48a8
commit 2c1baa717d
21 changed files with 737 additions and 267 deletions

View File

@@ -21,6 +21,13 @@ class TrackedChangeList {
this._trackedChanges = trackedChanges
}
/**
* @returns {IterableIterator<TrackedChange>}
*/
[Symbol.iterator]() {
return this._trackedChanges.values()
}
/**
*
* @param {TrackedChangeRawData[]} raw

View File

@@ -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',

View File

@@ -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)

View File

@@ -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(

View File

@@ -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
}
}
}
}

View File

@@ -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])

View File

@@ -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)
}
}

View File

@@ -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
})

View File

@@ -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)

View File

@@ -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)
})

View File

@@ -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({

View File

@@ -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}>

View File

@@ -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}>

View File

@@ -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 }
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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({

View File

@@ -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)
}
}

View File

@@ -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
*/

View File

@@ -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

View File

@@ -115,5 +115,6 @@ export const mockDoc = (
getPendingOp: () => null,
hasBufferedOps: () => false,
leaveAndCleanUpPromise: () => false,
isHistoryOT: () => false,
}
}