diff --git a/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js b/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js index b302865c70..5f4629ac91 100644 --- a/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js +++ b/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js @@ -21,6 +21,13 @@ class TrackedChangeList { this._trackedChanges = trackedChanges } + /** + * @returns {IterableIterator} + */ + [Symbol.iterator]() { + return this._trackedChanges.values() + } + /** * * @param {TrackedChangeRawData[]} raw diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index b7f4ac61a7..98f50d89da 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -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', diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index 7eadf27401..9b60857be0 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -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) diff --git a/services/web/frontend/js/features/ide-react/editor/document-container.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts index 28bcb955d1..3a466387df 100644 --- a/services/web/frontend/js/features/ide-react/editor/document-container.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -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( diff --git a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts index 5b362299d2..ab1b133a91 100644 --- a/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts +++ b/services/web/frontend/js/features/ide-react/editor/share-js-doc.ts @@ -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 + } + } + } } diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx index 0a2377b863..0908d3fd02 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-change.tsx @@ -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]) diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-current-file.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-current-file.tsx index b99872ac61..8edccf6084 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-current-file.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-current-file.tsx @@ -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) } } diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-overview-file.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-overview-file.tsx index bd236f95c6..d29d25fba9 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-overview-file.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-overview-file.tsx @@ -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 }) diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-overview.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-overview.tsx index 0f2a9ea49a..08eb62e0ec 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-overview.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-overview.tsx @@ -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() + 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) diff --git a/services/web/frontend/js/features/review-panel/components/review-panel-resolved-threads-menu.tsx b/services/web/frontend/js/features/review-panel/components/review-panel-resolved-threads-menu.tsx index 2501c0df89..d333917eaa 100644 --- a/services/web/frontend/js/features/review-panel/components/review-panel-resolved-threads-menu.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-panel-resolved-threads-menu.tsx @@ -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() + 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>() + const allComments = new Map< + string, + Change & { 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) }) diff --git a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx index cc16bfdba7..654f090061 100644 --- a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx @@ -108,16 +108,14 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({ const [tooltipStyle, setTooltipStyle] = useState() 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({ diff --git a/services/web/frontend/js/features/review-panel/context/ranges-context.tsx b/services/web/frontend/js/features/review-panel/context/ranges-context.tsx index f5e9c94c09..968ed0263e 100644 --- a/services/web/frontend/js/features/review-panel/context/ranges-context.tsx +++ b/services/web/frontend/js/features/review-panel/context/ranges-context.tsx @@ -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[] - comments: Change[] + changes: Array & { snapshotRange?: Range }> + comments: Array< + Change & { snapshotRange?: Range; resolved?: boolean } + > } export const RangesContext = createContext(undefined) type RangesActions = { - acceptChanges: (...ids: string[]) => Promise - rejectChanges: (...ids: string[]) => Promise + acceptChanges: ( + ...changes: Array & { snapshotRange?: Range }> + ) => Promise + rejectChanges: ( + ...changes: Array & { snapshotRange?: Range }> + ) => Promise } 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(undefined) export const RangesProvider: FC = ({ children }) => { @@ -89,11 +112,37 @@ export const RangesProvider: FC = ({ 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 = ({ 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 ( diff --git a/services/web/frontend/js/features/review-panel/context/threads-context.tsx b/services/web/frontend/js/features/review-panel/context/threads-context.tsx index 6b73073bfc..12f157b0dc 100644 --- a/services/web/frontend/js/features/review-panel/context/threads-context.tsx +++ b/services/web/frontend/js/features/review-panel/context/threads-context.tsx @@ -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 @@ -52,10 +61,13 @@ export const ThreadsProvider: FC = ({ children }) => { const { projectId } = useProjectContext() const { currentDocument } = useEditorOpenDocContext() const { isRestrictedTokenMember } = useEditorContext() + const view = useCodeMirrorViewContext() // const [error, setError] = useState() const [data, setData] = useState() + const isHistoryOT = currentDocument?.isHistoryOT() + // load the initial threads data useEffect(() => { if (isRestrictedTokenMember) { @@ -250,8 +262,12 @@ export const ThreadsProvider: FC = ({ 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 = ({ 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 = ({ 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 ( diff --git a/services/web/frontend/js/features/review-panel/hooks/use-project-ranges.ts b/services/web/frontend/js/features/review-panel/hooks/use-project-ranges.ts index 23ff05479e..384e2bbf69 100644 --- a/services/web/frontend/js/features/review-panel/hooks/use-project-ranges.ts +++ b/services/web/frontend/js/features/review-panel/hooks/use-project-ranges.ts @@ -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>() 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 } } diff --git a/services/web/frontend/js/features/review-panel/utils/snapshot-ranges.ts b/services/web/frontend/js/features/review-panel/utils/snapshot-ranges.ts new file mode 100644 index 0000000000..95a36e82f4 --- /dev/null +++ b/services/web/frontend/js/features/review-panel/utils/snapshot-ranges.ts @@ -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() + 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 +} diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts index 91a58599fb..c13504b4b4 100644 --- a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -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() +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({ }, }) -const updateCommentsEffect = StateEffect.define() - -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() - 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) - } -} diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts index 58cfa8712a..99d60262c5 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -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({ diff --git a/services/web/frontend/js/features/source-editor/utils/tracked-deletes.ts b/services/web/frontend/js/features/source-editor/utils/tracked-deletes.ts new file mode 100644 index 0000000000..1a1ea96221 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tracked-deletes.ts @@ -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) + } +} diff --git a/services/web/frontend/js/infrastructure/project-snapshot.ts b/services/web/frontend/js/infrastructure/project-snapshot.ts index a0d385ca02..37f583d5da 100644 --- a/services/web/frontend/js/infrastructure/project-snapshot.ts +++ b/services/web/frontend/js/infrastructure/project-snapshot.ts @@ -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 { + 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 */ diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index feb7fbf60d..5e5f4ffc41 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -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 diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts index a4944c1e97..9fccb91f37 100644 --- a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -115,5 +115,6 @@ export const mockDoc = ( getPendingOp: () => null, hasBufferedOps: () => false, leaveAndCleanUpPromise: () => false, + isHistoryOT: () => false, } }