diff --git a/libraries/ranges-tracker/package.json b/libraries/ranges-tracker/package.json index ad1dd0cb60..5585d8215d 100644 --- a/libraries/ranges-tracker/package.json +++ b/libraries/ranges-tracker/package.json @@ -2,8 +2,10 @@ "name": "@overleaf/ranges-tracker", "description": "Shared logic for syncing comments and tracked changes with operational transforms", "main": "index.cjs", + "types": "types/index.d.cts", "files": [ - "index.cjs" + "index.cjs", + "types" ], "author": "Overleaf (https://www.overleaf.com)", "private": true, diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx index 95196b78f7..8fe6928565 100644 --- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx @@ -16,7 +16,7 @@ import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchd import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useConnectionContext } from '@/features/ide-react/context/connection-context' import { debugConsole } from '@/utils/debugging' -import { Document } from '@/features/ide-react/editor/document' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' import { useLayoutContext } from '@/shared/context/layout-context' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { Doc } from '../../../../../types/doc' @@ -45,7 +45,7 @@ interface OpenDocOptions export type EditorManager = { getEditorType: () => EditorType | null showSymbolPalette: boolean - currentDocument: Document + currentDocument: DocumentContainer currentDocumentId: DocId | null getCurrentDocValue: () => string | null getCurrentDocId: () => DocId | null @@ -73,7 +73,7 @@ function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions { export type EditorScopeValue = { showSymbolPalette: false toggleSymbolPalette: () => void - sharejs_doc: Document | null + sharejs_doc: DocumentContainer | null open_doc_id: string | null open_doc_name: string | null opening: boolean @@ -101,7 +101,7 @@ export const EditorManagerProvider: FC = ({ children }) => { ) const [showVisual] = useScopeValue('editor.showVisual') const [currentDocument, setCurrentDocument] = - useScopeValue('editor.sharejs_doc') + useScopeValue('editor.sharejs_doc') const [openDocId, setOpenDocId] = useScopeValue( 'editor.open_doc_id' ) @@ -140,7 +140,7 @@ export const EditorManagerProvider: FC = ({ children }) => { // prevents circular dependencies in useCallbacks const [docError, setDocError] = useState<{ doc: Doc - document: Document + document: DocumentContainer error: Error | string meta?: Record editorContent?: string @@ -225,12 +225,12 @@ export const EditorManagerProvider: FC = ({ children }) => { [goToLineEmitter] ) - const unbindFromDocumentEvents = (document: Document) => { + const unbindFromDocumentEvents = (document: DocumentContainer) => { document.off() } const attachErrorHandlerToDocument = useCallback( - (doc: Doc, document: Document) => { + (doc: Doc, document: DocumentContainer) => { document.on( 'error', ( @@ -246,7 +246,7 @@ export const EditorManagerProvider: FC = ({ children }) => { ) const bindToDocumentEvents = useCallback( - (doc: Doc, document: Document) => { + (doc: Doc, document: DocumentContainer) => { attachErrorHandlerToDocument(doc, document) document.on('externalUpdate', (update: Update) => { @@ -276,7 +276,7 @@ export const EditorManagerProvider: FC = ({ children }) => { const syncTimeoutRef = useRef(null) const syncTrackChangesState = useCallback( - (doc: Document) => { + (doc: DocumentContainer) => { if (!doc) { return } @@ -310,7 +310,7 @@ export const EditorManagerProvider: FC = ({ children }) => { const doOpenNewDocument = useCallback( (doc: Doc) => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { debugConsole.log('[doOpenNewDocument] Opening...') const newDocument = openDocs.getDocument(doc._id) if (!newDocument) { @@ -344,7 +344,7 @@ export const EditorManagerProvider: FC = ({ children }) => { ) const openNewDocument = useCallback( - async (doc: Doc): Promise => { + async (doc: Doc): Promise => { // Leave the current document // - when we are opening a different new one, to avoid race conditions // between leaving and joining the same document diff --git a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts index f0eabb2c20..0ae9be2a2a 100644 --- a/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts +++ b/services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts @@ -27,7 +27,6 @@ import { debugConsole } from '@/utils/debugging' import { useEditorContext } from '@/shared/context/editor-context' import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' import ColorManager from '@/ide/colors/ColorManager' -// @ts-ignore import RangesTracker from '@overleaf/ranges-tracker' import * as ReviewPanel from '../types/review-panel-state' import { @@ -61,6 +60,12 @@ import { ReviewPanelCommentThreadsApi, } from '../../../../../../../types/review-panel/api' import { DateString } from '../../../../../../../types/helpers/date' +import { + Change, + CommentOperation, + EditOperation, +} from '../../../../../../../types/change' +import { RangesTrackerWithResolvedThreadIds } from '@/features/ide-react/editor/document-container' const dispatchReviewPanelEvent = (type: string, payload?: any) => { window.dispatchEvent( @@ -251,7 +256,9 @@ function useReviewPanelState(): ReviewPanelStateReactIde { }) }, [loadThreadsController.signal, projectId, setLoadingThreads]) - const rangesTrackers = useRef>({}) + const rangesTrackers = useRef< + Record + >({}) const refreshingRangeUsers = useRef(false) const refreshedForUserIds = useRef(new Set()) const refreshChangeUsers = useCallback( @@ -299,12 +306,14 @@ function useReviewPanelState(): ReviewPanelStateReactIde { const getChangeTracker = useCallback( (docId: DocId) => { if (!rangesTrackers.current[docId]) { - rangesTrackers.current[docId] = new RangesTracker() - rangesTrackers.current[docId].resolvedThreadIds = { - ...resolvedThreadIds, - } + const rangesTracker = new RangesTracker([], []) + ;( + rangesTracker as RangesTrackerWithResolvedThreadIds + ).resolvedThreadIds = { ...resolvedThreadIds } + rangesTrackers.current[docId] = + rangesTracker as RangesTrackerWithResolvedThreadIds } - return rangesTrackers.current[docId] + return rangesTrackers.current[docId]! }, [resolvedThreadIds] ) @@ -435,17 +444,18 @@ function useReviewPanelState(): ReviewPanelStateReactIde { if (!loadingThreadsInProgressRef.current) { for (const comment of rangesTracker.comments) { - deleteChanges.delete(comment.id) + const commentId = comment.id as ThreadId + deleteChanges.delete(commentId) let newComment: any if (localResolvedThreadIds[comment.op.t]) { - docResolvedComments[comment.id] ??= {} as ReviewPanelCommentEntry - newComment = docResolvedComments[comment.id] - delete docEntries[comment.id] + docResolvedComments[commentId] ??= {} as ReviewPanelCommentEntry + newComment = docResolvedComments[commentId] + delete docEntries[commentId] } else { - docEntries[comment.id] ??= {} as ReviewPanelEntry - newComment = docEntries[comment.id] - delete docResolvedComments[comment.id] + docEntries[commentId] ??= {} as ReviewPanelEntry + newComment = docEntries[commentId] + delete docResolvedComments[commentId] } newComment.type = 'comment' @@ -505,10 +515,12 @@ function useReviewPanelState(): ReviewPanelStateReactIde { } // The open doc range tracker is kept up to date in real-time so // replace any outdated info with this + const rangesTracker = currentDocument.ranges! + ;(rangesTracker as RangesTrackerWithResolvedThreadIds).resolvedThreadIds = { + ...resolvedThreadIds, + } rangesTrackers.current[currentDocument.doc_id as DocId] = - currentDocument.ranges - rangesTrackers.current[currentDocument.doc_id as DocId].resolvedThreadIds = - { ...resolvedThreadIds } + rangesTracker as RangesTrackerWithResolvedThreadIds currentDocument.on('flipped_pending_to_inflight', () => regenerateTrackChangesId(currentDocument) ) @@ -1034,8 +1046,8 @@ function useReviewPanelState(): ReviewPanelStateReactIde { type Doc = { id: DocId ranges: { - comments?: unknown[] - changes?: unknown[] + comments?: Change[] + changes?: Change[] } } @@ -1119,7 +1131,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde { } const { offset, length } = addCommentEntry - const threadId = RangesTracker.generateId() + const threadId = RangesTracker.generateId() as ThreadId setCommentThreads(prevState => ({ ...prevState, [threadId]: { ...getThread(threadId), submitting: true }, diff --git a/services/web/frontend/js/features/ide-react/editor/document.ts b/services/web/frontend/js/features/ide-react/editor/document-container.ts similarity index 93% rename from services/web/frontend/js/features/ide-react/editor/document.ts rename to services/web/frontend/js/features/ide-react/editor/document-container.ts index 8a7ce6a251..5987ad2887 100644 --- a/services/web/frontend/js/features/ide-react/editor/document.ts +++ b/services/web/frontend/js/features/ide-react/editor/document-container.ts @@ -1,11 +1,6 @@ -/* eslint-disable - camelcase, - n/handle-callback-err, - max-len, -*/ +/* eslint-disable camelcase */ // Migrated from services/web/frontend/js/ide/editor/Document.js -// @ts-ignore import RangesTracker from '@overleaf/ranges-tracker' import { ShareJsDoc } from './share-js-doc' import { debugConsole } from '@/utils/debugging' @@ -19,6 +14,7 @@ import { AnyOperation, Change, CommentOperation, + EditOperation, } from '../../../../../types/change' import { isCommentOperation, @@ -31,6 +27,7 @@ import { TrackChangesIdSeeds, Version, } from '@/features/ide-react/editor/types/document' +import { ThreadId } from '../../../../../types/review-panel/review-panel' const MAX_PENDING_OP_SIZE = 64 @@ -79,11 +76,22 @@ function getShareJsOpSize(shareJsOp: ShareJsOperation) { return shareJsOp.reduce((total, op) => total + getOpSize(op), 0) } -export class Document extends EventEmitter { +// TODO: define these in RangesTracker +type _RangesTracker = Omit & { + changes: Change[] + comments: Change[] + track_changes?: boolean +} + +export type RangesTrackerWithResolvedThreadIds = _RangesTracker & { + resolvedThreadIds: Record +} + +export class DocumentContainer extends EventEmitter { private connected: boolean private wantToBeJoined = false private chaosMonkeyTimer: number | null = null - private track_changes_as: string | null = null + public track_changes_as: string | null = null private joinCallbacks: JoinCallback[] = [] private leaveCallbacks: LeaveCallback[] = [] @@ -91,7 +99,9 @@ export class Document extends EventEmitter { doc?: ShareJsDoc cm6?: EditorFacade oldInflightOp?: ShareJsOperation - ranges: RangesTracker + + ranges?: _RangesTracker | RangesTrackerWithResolvedThreadIds + joined = false // This is set and read in useCodeMirrorScope @@ -103,7 +113,7 @@ export class Document extends EventEmitter { private readonly globalEditorWatchdogManager: EditorWatchdogManager, private readonly ideEventEmitter: IdeEventEmitter, private readonly eventLog: EventLog, - private readonly detachDoc: (docId: string, doc: Document) => void + private readonly detachDoc: (docId: string, doc: DocumentContainer) => void ) { super() this.connected = this.socket.socket.connected @@ -675,18 +685,18 @@ export class Document extends EventEmitter { let track_changes_as = null const remote_op = msg != null if (remote_op && msg?.meta.tc) { - old_id_seed = this.ranges.getIdSeed() - this.ranges.setIdSeed(msg.meta.tc) + old_id_seed = this.ranges!.getIdSeed() + this.ranges!.setIdSeed(msg.meta.tc) track_changes_as = msg.meta.user_id } else if (!remote_op && this.track_changes_as != null) { track_changes_as = this.track_changes_as } - this.ranges.track_changes = track_changes_as != null + this.ranges!.track_changes = track_changes_as != null for (const op of this.filterOps(ops)) { - this.ranges.applyOp(op, { user_id: track_changes_as }) + this.ranges!.applyOp(op, { user_id: track_changes_as }) } if (old_id_seed != null) { - this.ranges.setIdSeed(old_id_seed) + this.ranges!.setIdSeed(old_id_seed) } if (remote_op) { // With remote ops, the editor hasn't been updated when we receive this @@ -697,7 +707,10 @@ export class Document extends EventEmitter { } } - private catchUpRanges(changes: Change[], comments: CommentOperation[]) { + private catchUpRanges( + changes: Change[], + comments: Change[] + ) { // We've just been given the current server's ranges, but need to apply any local ops we have. // Reset to the server state then apply our local ops again. if (changes == null) { @@ -707,16 +720,16 @@ export class Document extends EventEmitter { comments = [] } this.emit('ranges:clear') - this.ranges.changes = changes - this.ranges.comments = comments - this.ranges.track_changes = this.doc?.track_changes + this.ranges!.changes = changes + this.ranges!.comments = comments + this.ranges!.track_changes = this.doc?.track_changes for (const op of this.filterOps(this.doc?.getInflightOp() || [])) { - this.ranges.setIdSeed(this.doc?.track_changes_id_seeds?.inflight) - this.ranges.applyOp(op, { user_id: this.track_changes_as }) + this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.inflight) + this.ranges!.applyOp(op, { user_id: this.track_changes_as }) } for (const op of this.filterOps(this.doc?.getPendingOp() || [])) { - this.ranges.setIdSeed(this.doc?.track_changes_id_seeds?.pending) - this.ranges.applyOp(op, { user_id: this.track_changes_as }) + this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.pending) + this.ranges!.applyOp(op, { user_id: this.track_changes_as }) } return this.emit('ranges:redraw') } diff --git a/services/web/frontend/js/features/ide-react/editor/open-documents.ts b/services/web/frontend/js/features/ide-react/editor/open-documents.ts index 1e631d8eba..cedd96b1f0 100644 --- a/services/web/frontend/js/features/ide-react/editor/open-documents.ts +++ b/services/web/frontend/js/features/ide-react/editor/open-documents.ts @@ -1,6 +1,6 @@ // Migrated from static methods of Document in Document.js -import { Document } from '@/features/ide-react/editor/document' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' import { debugConsole } from '@/utils/debugging' import { Socket } from '@/features/ide-react/connection/types/socket' import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' @@ -8,7 +8,7 @@ import { EventLog } from '@/features/ide-react/editor/event-log' import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager' export class OpenDocuments { - private openDocs = new Map() + private openDocs = new Map() // eslint-disable-next-line no-useless-constructor constructor( @@ -44,7 +44,7 @@ export class OpenDocuments { } private createDoc(docId: string) { - const doc = new Document( + const doc = new DocumentContainer( docId, this.socket, this.globalEditorWatchdogManager, @@ -55,7 +55,7 @@ export class OpenDocuments { this.openDocs.set(docId, doc) } - detachDoc(docId: string, doc: Document) { + detachDoc(docId: string, doc: DocumentContainer) { if (this.openDocs.get(docId) === doc) { debugConsole.log( `[detach] Removing document with ID (${docId}) from openDocs` diff --git a/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts b/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts index fa9dc2f2c7..ea39cf2a82 100644 --- a/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts +++ b/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts @@ -8,9 +8,12 @@ import { } from '../vertical-overflow' import { EditorSelection, EditorState } from '@codemirror/state' import { EditorView, ViewUpdate } from '@codemirror/view' -import { CurrentDoc } from '../../../../../../types/current-doc' import { fullHeightCoordsAtPos } from '../../utils/layer' import { debounce } from 'lodash' +import { Change, EditOperation } from '../../../../../../types/change' +import { ThreadId } from '../../../../../../types/review-panel/review-panel' +import { isDeleteOperation, isInsertOperation } from '@/utils/operations' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' // With less than this number of entries, don't bother culling to avoid // little UI jumps when scrolling. @@ -75,7 +78,7 @@ export type UpdateType = export const createChangeManager = ( view: EditorView, - currentDoc: CurrentDoc + currentDoc: DocumentContainer ): ChangeManager => { /** * Calculate the screen coordinates of each entry (change or comment), @@ -152,7 +155,7 @@ export const createChangeManager = ( /** * Add a comment (thread) to the ShareJS doc when it's created */ - const addComment = (offset: number, length: number, threadId: string) => { + const addComment = (offset: number, length: number, threadId: ThreadId) => { currentDoc.submitOp({ c: view.state.doc.sliceString(offset, offset + length), p: offset, @@ -164,14 +167,14 @@ export const createChangeManager = ( * Remove a comment (thread) from the range tracker when it's deleted */ const removeComment = (commentId: string) => { - currentDoc.ranges.removeCommentId(commentId) + currentDoc.ranges!.removeCommentId(commentId) } /** * Remove tracked changes from the range tracker when they're accepted */ const acceptChanges = (changeIds: string[]) => { - currentDoc.ranges.removeChangeIds(changeIds) + currentDoc.ranges!.removeChangeIds(changeIds) } /** @@ -179,7 +182,9 @@ export const createChangeManager = ( * and restore the original content */ const rejectChanges = (changeIds: string[]) => { - const changes: any[] = currentDoc.ranges.getChanges(changeIds) + const changes = currentDoc.ranges!.getChanges( + changeIds + ) as Change[] if (changes.length === 0) { return {} @@ -242,38 +247,30 @@ export const createChangeManager = ( const changesToDispatch = changes.map(change => { const { op } = change - const opType = 'i' in op ? 'i' : 'c' in op ? 'c' : 'd' + if (isInsertOperation(op)) { + const from = op.p + const content = op.i + const to = from + content.length - switch (opType) { - case 'd': { - return { - from: op.p, - to: op.p, - insert: op.d, - } + const text = view.state.doc.sliceString(from, to) + + if (text !== content) { + throw new Error( + `Op to be removed (${JSON.stringify( + change.op + )}) does not match editor text '${text}'` + ) } - case 'i': { - const from = op.p - const content = op.i - const to = from + content.length - - const text = view.state.doc.sliceString(from, to) - - if (text !== content) { - throw new Error( - `Op to be removed (${JSON.stringify( - change.op - )}) does not match editor text '${text}'` - ) - } - - return { from, to, insert: '' } - } - - default: { - throw new Error(`unknown change: ${JSON.stringify(change)}`) + return { from, to, insert: '' } + } else if (isDeleteOperation(op)) { + return { + from: op.p, + to: op.p, + insert: op.d, } + } else { + throw new Error(`unknown change type: ${JSON.stringify(change)}`) } }) diff --git a/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts b/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts index 3a5cd215b0..28a0f251ff 100644 --- a/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts +++ b/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts @@ -1,11 +1,11 @@ import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state' -import { CurrentDoc } from '../../../../../../types/current-doc' import { AnyOperation, Change, - ChangeOperation, CommentOperation, } from '../../../../../../types/change' +import { ThreadId } from '../../../../../../types/review-panel/review-panel' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' export type StoredComment = { text: string @@ -20,14 +20,14 @@ export type StoredComment = { * Find tracked comments within the range of the current transaction's changes */ export const findCommentsInCut = ( - currentDoc: CurrentDoc, + currentDoc: DocumentContainer, transaction: Transaction ) => { const items: StoredComment[] = [] transaction.changes.iterChanges((fromA, toA) => { - const comments = currentDoc.ranges.comments - .filter( + const comments = currentDoc + .ranges!.comments.filter( comment => fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA ) @@ -55,7 +55,7 @@ export const findCommentsInPaste = ( storedComments: StoredComment[], transaction: Transaction ) => { - const ops: ChangeOperation[] = [] + const ops: CommentOperation[] = [] transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { const insertedText = inserted.toString() @@ -71,7 +71,7 @@ export const findCommentsInPaste = ( ops.push({ c: text, p: fromB + offset, - t: comment.id, + t: comment.id as ThreadId, }) } } @@ -93,13 +93,13 @@ class CommentRangeValue extends RangeValue { * Find tracked comments with no content with the ranges of a transaction's changes */ export const findDetachedCommentsInChanges = ( - currentDoc: CurrentDoc, + currentDoc: DocumentContainer, transaction: Transaction ) => { const items: Range[] = [] transaction.changes.iterChanges((fromA, toA) => { - for (const comment of currentDoc.ranges.comments) { + for (const comment of currentDoc.ranges!.comments) { const content = comment.op.c // TODO: handle comments that were never attached @@ -124,7 +124,7 @@ export const findDetachedCommentsInChanges = ( * (used when restoring comments on paste) */ const submitOps = ( - currentDoc: CurrentDoc, + currentDoc: DocumentContainer, ops: AnyOperation[], transaction: Transaction ) => { @@ -133,14 +133,14 @@ const submitOps = ( } // Check that comments still match text. Will throw error if not. - currentDoc.ranges.validate(transaction.state.doc.toString()) + currentDoc.ranges!.validate(transaction.state.doc.toString()) } /** * Wait for the ShareJS doc to fire an event, then submit the operations. */ const submitOpsAfterEvent = ( - currentDoc: CurrentDoc, + currentDoc: DocumentContainer, eventName: string, ops: AnyOperation[], transaction: Transaction @@ -161,7 +161,7 @@ const submitOpsAfterEvent = ( * Look through the comments stored on cut, and restore those in text that matches the pasted text. */ export const restoreCommentsOnPaste = ( - currentDoc: CurrentDoc, + currentDoc: DocumentContainer, transaction: Transaction, storedComments: StoredComment[] ) => { @@ -183,18 +183,18 @@ export const restoreCommentsOnPaste = ( * When undoing a change, find comments from the original content and restore them. */ export const restoreDetachedComments = ( - currentDoc: CurrentDoc, + currentDoc: DocumentContainer, transaction: Transaction, storedComments: RangeSet ) => { - const ops: ChangeOperation[] = [] + const ops: CommentOperation[] = [] const cursor = storedComments.iter() while (cursor.value) { const { id } = cursor.value.comment - const comment = currentDoc.ranges.comments.find(item => item.id === id) + const comment = currentDoc.ranges!.comments.find(item => item.id === id) // check that the comment still exists and is detached if (comment && comment.op.c === '') { 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 151982dca0..f71dedba18 100644 --- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -1,9 +1,9 @@ import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state' import { EditorView, ViewPlugin } from '@codemirror/view' import { EventEmitter } from 'events' -import { CurrentDoc } from '../../../../../types/current-doc' import { ShareDoc } from '../../../../../types/share-doc' import { debugConsole } from '@/utils/debugging' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' /* * Integrate CodeMirror 6 with the real-time system, via ShareJS. @@ -34,7 +34,7 @@ export type ChangeDescription = { * A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document. */ export const realtime = ( - { currentDoc }: { currentDoc: CurrentDoc }, + { currentDoc }: { currentDoc: DocumentContainer }, handleError: (error: Error) => void ) => { const realtimePlugin = ViewPlugin.define(view => { diff --git a/services/web/frontend/js/features/source-editor/extensions/track-changes.ts b/services/web/frontend/js/features/source-editor/extensions/track-changes.ts index c9f1fe4d6a..c6188e1ea1 100644 --- a/services/web/frontend/js/features/source-editor/extensions/track-changes.ts +++ b/services/web/frontend/js/features/source-editor/extensions/track-changes.ts @@ -21,15 +21,14 @@ import { StoredComment, } from './changes/comments' import { invertedEffects } from '@codemirror/commands' -import { CurrentDoc } from '../../../../../types/current-doc' import { Change, DeleteOperation } from '../../../../../types/change' import { ChangeManager } from './changes/change-manager' import { debugConsole } from '@/utils/debugging' +import { isCommentOperation, isDeleteOperation } from '@/utils/operations' import { - isChangeOperation, - isCommentOperation, - isDeleteOperation, -} from '@/utils/operations' + DocumentContainer, + RangesTrackerWithResolvedThreadIds, +} from '@/features/ide-react/editor/document-container' const clearChangesEffect = StateEffect.define() const buildChangesEffect = StateEffect.define() @@ -46,7 +45,7 @@ const restoreDetachedCommentsEffect = StateEffect.define>({ }) type Options = { - currentDoc: CurrentDoc + currentDoc: DocumentContainer loadingThreads: boolean } @@ -182,7 +181,11 @@ export const buildChangeMarkers = () => { } } -const buildChangeDecorations = (currentDoc: CurrentDoc) => { +const buildChangeDecorations = (currentDoc: DocumentContainer) => { + if (!currentDoc.ranges) { + return Decoration.none + } + const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments] const decorations = [] @@ -245,7 +248,7 @@ class ChangeCalloutWidget extends WidgetType { } } -const createChangeRange = (change: Change, currentDoc: CurrentDoc) => { +const createChangeRange = (change: Change, currentDoc: DocumentContainer) => { const { id, metadata, op } = change const from = op.p @@ -273,14 +276,18 @@ const createChangeRange = (change: Change, currentDoc: CurrentDoc) => { return [calloutWidget.range(from, from), changeWidget.range(from, from)] } - if (isChangeOperation(op) && currentDoc.ranges.resolvedThreadIds[op.t]) { + const _isCommentOperation = isCommentOperation(op) + + if ( + _isCommentOperation && + (currentDoc.ranges as RangesTrackerWithResolvedThreadIds) + .resolvedThreadIds![op.t] + ) { return [] } - const isChangeOrCommentOperation = - isChangeOperation(op) || isCommentOperation(op) - const opType = isChangeOrCommentOperation ? 'c' : 'i' - const changedText = isChangeOrCommentOperation ? op.c : op.i + const opType = _isCommentOperation ? 'c' : 'i' + const changedText = _isCommentOperation ? op.c : op.i const to = from + changedText.length // Mark decorations must not be empty diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 6ac5849491..28622cdc01 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -47,7 +47,6 @@ import { import { setKeybindings } from '../extensions/keybindings' import { Highlight } from '../../../../../types/highlight' import { EditorView } from '@codemirror/view' -import { CurrentDoc } from '../../../../../types/current-doc' import { useErrorHandler } from 'react-error-boundary' import { setVisual } from '../extensions/visual/visual' import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' @@ -56,6 +55,7 @@ import { setDocName } from '@/features/source-editor/extensions/doc-name' import isValidTexFile from '@/main/is-valid-tex-file' import { captureException } from '@/infrastructure/error-reporter' import grammarlyExtensionPresent from '@/shared/utils/grammarly' +import { DocumentContainer } from '@/features/ide-react/editor/document-container' function useCodeMirrorScope(view: EditorView) { const ide = useIdeContext() @@ -71,7 +71,9 @@ function useCodeMirrorScope(view: EditorView) { const [loadingThreads] = useScopeValue('loadingThreads') - const [currentDoc] = useScopeValue('editor.sharejs_doc') + const [currentDoc] = useScopeValue( + 'editor.sharejs_doc' + ) const [docName] = useScopeValue('editor.open_doc_name') const [trackChanges] = useScopeValue('editor.trackChanges') diff --git a/services/web/frontend/js/utils/operations.ts b/services/web/frontend/js/utils/operations.ts index d5c26a6513..51af2ee3b0 100644 --- a/services/web/frontend/js/utils/operations.ts +++ b/services/web/frontend/js/utils/operations.ts @@ -1,5 +1,4 @@ import { - ChangeOperation, CommentOperation, DeleteOperation, InsertOperation, @@ -8,9 +7,7 @@ import { export const isInsertOperation = (op: Operation): op is InsertOperation => 'i' in op -export const isChangeOperation = (op: Operation): op is ChangeOperation => - 'c' in op && 't' in op export const isCommentOperation = (op: Operation): op is CommentOperation => - 'c' in op && !('t' in op) + 'c' in op export const isDeleteOperation = (op: Operation): op is DeleteOperation => 'd' in op diff --git a/services/web/types/change.ts b/services/web/types/change.ts index 4a50aa9bf5..a1fd462574 100644 --- a/services/web/types/change.ts +++ b/services/web/types/change.ts @@ -1,36 +1,31 @@ +import { ThreadId } from './review-panel/review-panel' +import { UserId } from './user' + export interface Operation { - p: number + p: number // position } export interface InsertOperation extends Operation { - i: string - t: string -} - -export interface ChangeOperation extends Operation { - c: string - t: string + i: string // inserted text } export interface DeleteOperation extends Operation { - d: string + d: string // deleted text } export interface CommentOperation extends Operation { - c: string + c: string // comment text + t: ThreadId // thread/comment id } -export type NonCommentOperation = - | InsertOperation - | ChangeOperation - | DeleteOperation +export type EditOperation = InsertOperation | DeleteOperation -export type AnyOperation = NonCommentOperation | CommentOperation +export type AnyOperation = EditOperation | CommentOperation export type Change = { id: string metadata?: { - user_id: string + user_id: UserId | null ts: Date } op: T diff --git a/services/web/types/current-doc.ts b/services/web/types/current-doc.ts deleted file mode 100644 index d6a77ac1c6..0000000000 --- a/services/web/types/current-doc.ts +++ /dev/null @@ -1,39 +0,0 @@ -import EventEmitter from '../frontend/js/utils/EventEmitter' -import { ShareDoc } from './share-doc' -import { EditorFacade } from '../frontend/js/features/source-editor/extensions/realtime' -import { - AnyOperation, - Change, - ChangeOperation, - CommentOperation, - DeleteOperation, - InsertOperation, -} from './change' - -// type for the Document class in ide/editor/Document.js -// note: this is a custom EventEmitter class - -// TODO: MIGRATION: This doesn't match the type for -// ide-react/editor/document.ts, which has a nullable `ranges` property and some -// other quirks. They should match. -export interface CurrentDoc extends EventEmitter { - doc_id: string - docName: string - doc: ShareDoc | null - track_changes_as: string | null - ranges: { - changes: Change[] - comments: Change[] - resolvedThreadIds: Record - removeCommentId: (id: string) => void - removeChangeIds: (ids: string[]) => void - getChanges: ( - ids: string[] - ) => Change[] - validate: (text: string) => void - } - attachToCM6: (editor: EditorFacade) => void - detachFromCM6: () => void - submitOp: (op: AnyOperation) => void - getSnapshot: () => string -}