Standardise types for ranges (#16927)

GitOrigin-RevId: 28dd0eb67e1684e6bd0e452d15315ce1f9e3481a
This commit is contained in:
Alf Eaton 2024-02-08 10:10:46 +00:00 committed by Copybot
parent c997d1dc2b
commit 7b3ffb9fae
13 changed files with 171 additions and 185 deletions

View file

@ -2,8 +2,10 @@
"name": "@overleaf/ranges-tracker", "name": "@overleaf/ranges-tracker",
"description": "Shared logic for syncing comments and tracked changes with operational transforms", "description": "Shared logic for syncing comments and tracked changes with operational transforms",
"main": "index.cjs", "main": "index.cjs",
"types": "types/index.d.cts",
"files": [ "files": [
"index.cjs" "index.cjs",
"types"
], ],
"author": "Overleaf (https://www.overleaf.com)", "author": "Overleaf (https://www.overleaf.com)",
"private": true, "private": true,

View file

@ -16,7 +16,7 @@ import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchd
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { useConnectionContext } from '@/features/ide-react/context/connection-context' import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import { debugConsole } from '@/utils/debugging' 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 { useLayoutContext } from '@/shared/context/layout-context'
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
import { Doc } from '../../../../../types/doc' import { Doc } from '../../../../../types/doc'
@ -45,7 +45,7 @@ interface OpenDocOptions
export type EditorManager = { export type EditorManager = {
getEditorType: () => EditorType | null getEditorType: () => EditorType | null
showSymbolPalette: boolean showSymbolPalette: boolean
currentDocument: Document currentDocument: DocumentContainer
currentDocumentId: DocId | null currentDocumentId: DocId | null
getCurrentDocValue: () => string | null getCurrentDocValue: () => string | null
getCurrentDocId: () => DocId | null getCurrentDocId: () => DocId | null
@ -73,7 +73,7 @@ function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
export type EditorScopeValue = { export type EditorScopeValue = {
showSymbolPalette: false showSymbolPalette: false
toggleSymbolPalette: () => void toggleSymbolPalette: () => void
sharejs_doc: Document | null sharejs_doc: DocumentContainer | null
open_doc_id: string | null open_doc_id: string | null
open_doc_name: string | null open_doc_name: string | null
opening: boolean opening: boolean
@ -101,7 +101,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
) )
const [showVisual] = useScopeValue<boolean>('editor.showVisual') const [showVisual] = useScopeValue<boolean>('editor.showVisual')
const [currentDocument, setCurrentDocument] = const [currentDocument, setCurrentDocument] =
useScopeValue<Document>('editor.sharejs_doc') useScopeValue<DocumentContainer>('editor.sharejs_doc')
const [openDocId, setOpenDocId] = useScopeValue<DocId | null>( const [openDocId, setOpenDocId] = useScopeValue<DocId | null>(
'editor.open_doc_id' 'editor.open_doc_id'
) )
@ -140,7 +140,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
// prevents circular dependencies in useCallbacks // prevents circular dependencies in useCallbacks
const [docError, setDocError] = useState<{ const [docError, setDocError] = useState<{
doc: Doc doc: Doc
document: Document document: DocumentContainer
error: Error | string error: Error | string
meta?: Record<string, any> meta?: Record<string, any>
editorContent?: string editorContent?: string
@ -225,12 +225,12 @@ export const EditorManagerProvider: FC = ({ children }) => {
[goToLineEmitter] [goToLineEmitter]
) )
const unbindFromDocumentEvents = (document: Document) => { const unbindFromDocumentEvents = (document: DocumentContainer) => {
document.off() document.off()
} }
const attachErrorHandlerToDocument = useCallback( const attachErrorHandlerToDocument = useCallback(
(doc: Doc, document: Document) => { (doc: Doc, document: DocumentContainer) => {
document.on( document.on(
'error', 'error',
( (
@ -246,7 +246,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
) )
const bindToDocumentEvents = useCallback( const bindToDocumentEvents = useCallback(
(doc: Doc, document: Document) => { (doc: Doc, document: DocumentContainer) => {
attachErrorHandlerToDocument(doc, document) attachErrorHandlerToDocument(doc, document)
document.on('externalUpdate', (update: Update) => { document.on('externalUpdate', (update: Update) => {
@ -276,7 +276,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
const syncTimeoutRef = useRef<number | null>(null) const syncTimeoutRef = useRef<number | null>(null)
const syncTrackChangesState = useCallback( const syncTrackChangesState = useCallback(
(doc: Document) => { (doc: DocumentContainer) => {
if (!doc) { if (!doc) {
return return
} }
@ -310,7 +310,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
const doOpenNewDocument = useCallback( const doOpenNewDocument = useCallback(
(doc: Doc) => (doc: Doc) =>
new Promise<Document>((resolve, reject) => { new Promise<DocumentContainer>((resolve, reject) => {
debugConsole.log('[doOpenNewDocument] Opening...') debugConsole.log('[doOpenNewDocument] Opening...')
const newDocument = openDocs.getDocument(doc._id) const newDocument = openDocs.getDocument(doc._id)
if (!newDocument) { if (!newDocument) {
@ -344,7 +344,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
) )
const openNewDocument = useCallback( const openNewDocument = useCallback(
async (doc: Doc): Promise<Document> => { async (doc: Doc): Promise<DocumentContainer> => {
// Leave the current document // Leave the current document
// - when we are opening a different new one, to avoid race conditions // - when we are opening a different new one, to avoid race conditions
// between leaving and joining the same document // between leaving and joining the same document

View file

@ -27,7 +27,6 @@ import { debugConsole } from '@/utils/debugging'
import { useEditorContext } from '@/shared/context/editor-context' import { useEditorContext } from '@/shared/context/editor-context'
import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
import ColorManager from '@/ide/colors/ColorManager' import ColorManager from '@/ide/colors/ColorManager'
// @ts-ignore
import RangesTracker from '@overleaf/ranges-tracker' import RangesTracker from '@overleaf/ranges-tracker'
import * as ReviewPanel from '../types/review-panel-state' import * as ReviewPanel from '../types/review-panel-state'
import { import {
@ -61,6 +60,12 @@ import {
ReviewPanelCommentThreadsApi, ReviewPanelCommentThreadsApi,
} from '../../../../../../../types/review-panel/api' } from '../../../../../../../types/review-panel/api'
import { DateString } from '../../../../../../../types/helpers/date' 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) => { const dispatchReviewPanelEvent = (type: string, payload?: any) => {
window.dispatchEvent( window.dispatchEvent(
@ -251,7 +256,9 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
}) })
}, [loadThreadsController.signal, projectId, setLoadingThreads]) }, [loadThreadsController.signal, projectId, setLoadingThreads])
const rangesTrackers = useRef<Record<DocId, RangesTracker>>({}) const rangesTrackers = useRef<
Record<DocId, RangesTrackerWithResolvedThreadIds>
>({})
const refreshingRangeUsers = useRef(false) const refreshingRangeUsers = useRef(false)
const refreshedForUserIds = useRef(new Set<UserId>()) const refreshedForUserIds = useRef(new Set<UserId>())
const refreshChangeUsers = useCallback( const refreshChangeUsers = useCallback(
@ -299,12 +306,14 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
const getChangeTracker = useCallback( const getChangeTracker = useCallback(
(docId: DocId) => { (docId: DocId) => {
if (!rangesTrackers.current[docId]) { if (!rangesTrackers.current[docId]) {
rangesTrackers.current[docId] = new RangesTracker() const rangesTracker = new RangesTracker([], [])
rangesTrackers.current[docId].resolvedThreadIds = { ;(
...resolvedThreadIds, rangesTracker as RangesTrackerWithResolvedThreadIds
} ).resolvedThreadIds = { ...resolvedThreadIds }
rangesTrackers.current[docId] =
rangesTracker as RangesTrackerWithResolvedThreadIds
} }
return rangesTrackers.current[docId] return rangesTrackers.current[docId]!
}, },
[resolvedThreadIds] [resolvedThreadIds]
) )
@ -435,17 +444,18 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
if (!loadingThreadsInProgressRef.current) { if (!loadingThreadsInProgressRef.current) {
for (const comment of rangesTracker.comments) { for (const comment of rangesTracker.comments) {
deleteChanges.delete(comment.id) const commentId = comment.id as ThreadId
deleteChanges.delete(commentId)
let newComment: any let newComment: any
if (localResolvedThreadIds[comment.op.t]) { if (localResolvedThreadIds[comment.op.t]) {
docResolvedComments[comment.id] ??= {} as ReviewPanelCommentEntry docResolvedComments[commentId] ??= {} as ReviewPanelCommentEntry
newComment = docResolvedComments[comment.id] newComment = docResolvedComments[commentId]
delete docEntries[comment.id] delete docEntries[commentId]
} else { } else {
docEntries[comment.id] ??= {} as ReviewPanelEntry docEntries[commentId] ??= {} as ReviewPanelEntry
newComment = docEntries[comment.id] newComment = docEntries[commentId]
delete docResolvedComments[comment.id] delete docResolvedComments[commentId]
} }
newComment.type = 'comment' newComment.type = 'comment'
@ -505,10 +515,12 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
} }
// The open doc range tracker is kept up to date in real-time so // The open doc range tracker is kept up to date in real-time so
// replace any outdated info with this // replace any outdated info with this
const rangesTracker = currentDocument.ranges!
;(rangesTracker as RangesTrackerWithResolvedThreadIds).resolvedThreadIds = {
...resolvedThreadIds,
}
rangesTrackers.current[currentDocument.doc_id as DocId] = rangesTrackers.current[currentDocument.doc_id as DocId] =
currentDocument.ranges rangesTracker as RangesTrackerWithResolvedThreadIds
rangesTrackers.current[currentDocument.doc_id as DocId].resolvedThreadIds =
{ ...resolvedThreadIds }
currentDocument.on('flipped_pending_to_inflight', () => currentDocument.on('flipped_pending_to_inflight', () =>
regenerateTrackChangesId(currentDocument) regenerateTrackChangesId(currentDocument)
) )
@ -1034,8 +1046,8 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
type Doc = { type Doc = {
id: DocId id: DocId
ranges: { ranges: {
comments?: unknown[] comments?: Change<CommentOperation>[]
changes?: unknown[] changes?: Change<EditOperation>[]
} }
} }
@ -1119,7 +1131,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
} }
const { offset, length } = addCommentEntry const { offset, length } = addCommentEntry
const threadId = RangesTracker.generateId() const threadId = RangesTracker.generateId() as ThreadId
setCommentThreads(prevState => ({ setCommentThreads(prevState => ({
...prevState, ...prevState,
[threadId]: { ...getThread(threadId), submitting: true }, [threadId]: { ...getThread(threadId), submitting: true },

View file

@ -1,11 +1,6 @@
/* eslint-disable /* eslint-disable camelcase */
camelcase,
n/handle-callback-err,
max-len,
*/
// Migrated from services/web/frontend/js/ide/editor/Document.js // Migrated from services/web/frontend/js/ide/editor/Document.js
// @ts-ignore
import RangesTracker from '@overleaf/ranges-tracker' import RangesTracker from '@overleaf/ranges-tracker'
import { ShareJsDoc } from './share-js-doc' import { ShareJsDoc } from './share-js-doc'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
@ -19,6 +14,7 @@ import {
AnyOperation, AnyOperation,
Change, Change,
CommentOperation, CommentOperation,
EditOperation,
} from '../../../../../types/change' } from '../../../../../types/change'
import { import {
isCommentOperation, isCommentOperation,
@ -31,6 +27,7 @@ import {
TrackChangesIdSeeds, TrackChangesIdSeeds,
Version, Version,
} from '@/features/ide-react/editor/types/document' } from '@/features/ide-react/editor/types/document'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
const MAX_PENDING_OP_SIZE = 64 const MAX_PENDING_OP_SIZE = 64
@ -79,11 +76,22 @@ function getShareJsOpSize(shareJsOp: ShareJsOperation) {
return shareJsOp.reduce((total, op) => total + getOpSize(op), 0) return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
} }
export class Document extends EventEmitter { // TODO: define these in RangesTracker
type _RangesTracker = Omit<RangesTracker, 'changes' | 'comments'> & {
changes: Change<EditOperation>[]
comments: Change<CommentOperation>[]
track_changes?: boolean
}
export type RangesTrackerWithResolvedThreadIds = _RangesTracker & {
resolvedThreadIds: Record<ThreadId, boolean>
}
export class DocumentContainer extends EventEmitter {
private connected: boolean private connected: boolean
private wantToBeJoined = false private wantToBeJoined = false
private chaosMonkeyTimer: number | null = null private chaosMonkeyTimer: number | null = null
private track_changes_as: string | null = null public track_changes_as: string | null = null
private joinCallbacks: JoinCallback[] = [] private joinCallbacks: JoinCallback[] = []
private leaveCallbacks: LeaveCallback[] = [] private leaveCallbacks: LeaveCallback[] = []
@ -91,7 +99,9 @@ export class Document extends EventEmitter {
doc?: ShareJsDoc doc?: ShareJsDoc
cm6?: EditorFacade cm6?: EditorFacade
oldInflightOp?: ShareJsOperation oldInflightOp?: ShareJsOperation
ranges: RangesTracker
ranges?: _RangesTracker | RangesTrackerWithResolvedThreadIds
joined = false joined = false
// This is set and read in useCodeMirrorScope // This is set and read in useCodeMirrorScope
@ -103,7 +113,7 @@ export class Document extends EventEmitter {
private readonly globalEditorWatchdogManager: EditorWatchdogManager, private readonly globalEditorWatchdogManager: EditorWatchdogManager,
private readonly ideEventEmitter: IdeEventEmitter, private readonly ideEventEmitter: IdeEventEmitter,
private readonly eventLog: EventLog, private readonly eventLog: EventLog,
private readonly detachDoc: (docId: string, doc: Document) => void private readonly detachDoc: (docId: string, doc: DocumentContainer) => void
) { ) {
super() super()
this.connected = this.socket.socket.connected this.connected = this.socket.socket.connected
@ -675,18 +685,18 @@ export class Document extends EventEmitter {
let track_changes_as = null let track_changes_as = null
const remote_op = msg != null const remote_op = msg != null
if (remote_op && msg?.meta.tc) { if (remote_op && msg?.meta.tc) {
old_id_seed = this.ranges.getIdSeed() old_id_seed = this.ranges!.getIdSeed()
this.ranges.setIdSeed(msg.meta.tc) this.ranges!.setIdSeed(msg.meta.tc)
track_changes_as = msg.meta.user_id track_changes_as = msg.meta.user_id
} else if (!remote_op && this.track_changes_as != null) { } else if (!remote_op && this.track_changes_as != null) {
track_changes_as = this.track_changes_as 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)) { 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) { if (old_id_seed != null) {
this.ranges.setIdSeed(old_id_seed) this.ranges!.setIdSeed(old_id_seed)
} }
if (remote_op) { if (remote_op) {
// With remote ops, the editor hasn't been updated when we receive this // 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<EditOperation>[],
comments: Change<CommentOperation>[]
) {
// We've just been given the current server's ranges, but need to apply any local ops we have. // 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. // Reset to the server state then apply our local ops again.
if (changes == null) { if (changes == null) {
@ -707,16 +720,16 @@ export class Document extends EventEmitter {
comments = [] comments = []
} }
this.emit('ranges:clear') this.emit('ranges:clear')
this.ranges.changes = changes this.ranges!.changes = changes
this.ranges.comments = comments this.ranges!.comments = comments
this.ranges.track_changes = this.doc?.track_changes this.ranges!.track_changes = this.doc?.track_changes
for (const op of this.filterOps(this.doc?.getInflightOp() || [])) { for (const op of this.filterOps(this.doc?.getInflightOp() || [])) {
this.ranges.setIdSeed(this.doc?.track_changes_id_seeds?.inflight) this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.inflight)
this.ranges.applyOp(op, { user_id: this.track_changes_as }) this.ranges!.applyOp(op, { user_id: this.track_changes_as })
} }
for (const op of this.filterOps(this.doc?.getPendingOp() || [])) { for (const op of this.filterOps(this.doc?.getPendingOp() || [])) {
this.ranges.setIdSeed(this.doc?.track_changes_id_seeds?.pending) this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.pending)
this.ranges.applyOp(op, { user_id: this.track_changes_as }) this.ranges!.applyOp(op, { user_id: this.track_changes_as })
} }
return this.emit('ranges:redraw') return this.emit('ranges:redraw')
} }

View file

@ -1,6 +1,6 @@
// Migrated from static methods of Document in Document.js // 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 { debugConsole } from '@/utils/debugging'
import { Socket } from '@/features/ide-react/connection/types/socket' import { Socket } from '@/features/ide-react/connection/types/socket'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter' 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' import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
export class OpenDocuments { export class OpenDocuments {
private openDocs = new Map<string, Document>() private openDocs = new Map<string, DocumentContainer>()
// eslint-disable-next-line no-useless-constructor // eslint-disable-next-line no-useless-constructor
constructor( constructor(
@ -44,7 +44,7 @@ export class OpenDocuments {
} }
private createDoc(docId: string) { private createDoc(docId: string) {
const doc = new Document( const doc = new DocumentContainer(
docId, docId,
this.socket, this.socket,
this.globalEditorWatchdogManager, this.globalEditorWatchdogManager,
@ -55,7 +55,7 @@ export class OpenDocuments {
this.openDocs.set(docId, doc) this.openDocs.set(docId, doc)
} }
detachDoc(docId: string, doc: Document) { detachDoc(docId: string, doc: DocumentContainer) {
if (this.openDocs.get(docId) === doc) { if (this.openDocs.get(docId) === doc) {
debugConsole.log( debugConsole.log(
`[detach] Removing document with ID (${docId}) from openDocs` `[detach] Removing document with ID (${docId}) from openDocs`

View file

@ -8,9 +8,12 @@ import {
} from '../vertical-overflow' } from '../vertical-overflow'
import { EditorSelection, EditorState } from '@codemirror/state' import { EditorSelection, EditorState } from '@codemirror/state'
import { EditorView, ViewUpdate } from '@codemirror/view' import { EditorView, ViewUpdate } from '@codemirror/view'
import { CurrentDoc } from '../../../../../../types/current-doc'
import { fullHeightCoordsAtPos } from '../../utils/layer' import { fullHeightCoordsAtPos } from '../../utils/layer'
import { debounce } from 'lodash' 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 // With less than this number of entries, don't bother culling to avoid
// little UI jumps when scrolling. // little UI jumps when scrolling.
@ -75,7 +78,7 @@ export type UpdateType =
export const createChangeManager = ( export const createChangeManager = (
view: EditorView, view: EditorView,
currentDoc: CurrentDoc currentDoc: DocumentContainer
): ChangeManager => { ): ChangeManager => {
/** /**
* Calculate the screen coordinates of each entry (change or comment), * 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 * 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({ currentDoc.submitOp({
c: view.state.doc.sliceString(offset, offset + length), c: view.state.doc.sliceString(offset, offset + length),
p: offset, p: offset,
@ -164,14 +167,14 @@ export const createChangeManager = (
* Remove a comment (thread) from the range tracker when it's deleted * Remove a comment (thread) from the range tracker when it's deleted
*/ */
const removeComment = (commentId: string) => { const removeComment = (commentId: string) => {
currentDoc.ranges.removeCommentId(commentId) currentDoc.ranges!.removeCommentId(commentId)
} }
/** /**
* Remove tracked changes from the range tracker when they're accepted * Remove tracked changes from the range tracker when they're accepted
*/ */
const acceptChanges = (changeIds: string[]) => { const acceptChanges = (changeIds: string[]) => {
currentDoc.ranges.removeChangeIds(changeIds) currentDoc.ranges!.removeChangeIds(changeIds)
} }
/** /**
@ -179,7 +182,9 @@ export const createChangeManager = (
* and restore the original content * and restore the original content
*/ */
const rejectChanges = (changeIds: string[]) => { const rejectChanges = (changeIds: string[]) => {
const changes: any[] = currentDoc.ranges.getChanges(changeIds) const changes = currentDoc.ranges!.getChanges(
changeIds
) as Change<EditOperation>[]
if (changes.length === 0) { if (changes.length === 0) {
return {} return {}
@ -242,38 +247,30 @@ export const createChangeManager = (
const changesToDispatch = changes.map(change => { const changesToDispatch = changes.map(change => {
const { op } = 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) { const text = view.state.doc.sliceString(from, to)
case 'd': {
return { if (text !== content) {
from: op.p, throw new Error(
to: op.p, `Op to be removed (${JSON.stringify(
insert: op.d, change.op
} )}) does not match editor text '${text}'`
)
} }
case 'i': { return { from, to, insert: '' }
const from = op.p } else if (isDeleteOperation(op)) {
const content = op.i return {
const to = from + content.length from: op.p,
to: op.p,
const text = view.state.doc.sliceString(from, to) insert: op.d,
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)}`)
} }
} else {
throw new Error(`unknown change type: ${JSON.stringify(change)}`)
} }
}) })

View file

@ -1,11 +1,11 @@
import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state' import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state'
import { CurrentDoc } from '../../../../../../types/current-doc'
import { import {
AnyOperation, AnyOperation,
Change, Change,
ChangeOperation,
CommentOperation, CommentOperation,
} from '../../../../../../types/change' } from '../../../../../../types/change'
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
export type StoredComment = { export type StoredComment = {
text: string text: string
@ -20,14 +20,14 @@ export type StoredComment = {
* Find tracked comments within the range of the current transaction's changes * Find tracked comments within the range of the current transaction's changes
*/ */
export const findCommentsInCut = ( export const findCommentsInCut = (
currentDoc: CurrentDoc, currentDoc: DocumentContainer,
transaction: Transaction transaction: Transaction
) => { ) => {
const items: StoredComment[] = [] const items: StoredComment[] = []
transaction.changes.iterChanges((fromA, toA) => { transaction.changes.iterChanges((fromA, toA) => {
const comments = currentDoc.ranges.comments const comments = currentDoc
.filter( .ranges!.comments.filter(
comment => comment =>
fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA
) )
@ -55,7 +55,7 @@ export const findCommentsInPaste = (
storedComments: StoredComment[], storedComments: StoredComment[],
transaction: Transaction transaction: Transaction
) => { ) => {
const ops: ChangeOperation[] = [] const ops: CommentOperation[] = []
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const insertedText = inserted.toString() const insertedText = inserted.toString()
@ -71,7 +71,7 @@ export const findCommentsInPaste = (
ops.push({ ops.push({
c: text, c: text,
p: fromB + offset, 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 * Find tracked comments with no content with the ranges of a transaction's changes
*/ */
export const findDetachedCommentsInChanges = ( export const findDetachedCommentsInChanges = (
currentDoc: CurrentDoc, currentDoc: DocumentContainer,
transaction: Transaction transaction: Transaction
) => { ) => {
const items: Range<CommentRangeValue>[] = [] const items: Range<CommentRangeValue>[] = []
transaction.changes.iterChanges((fromA, toA) => { transaction.changes.iterChanges((fromA, toA) => {
for (const comment of currentDoc.ranges.comments) { for (const comment of currentDoc.ranges!.comments) {
const content = comment.op.c const content = comment.op.c
// TODO: handle comments that were never attached // TODO: handle comments that were never attached
@ -124,7 +124,7 @@ export const findDetachedCommentsInChanges = (
* (used when restoring comments on paste) * (used when restoring comments on paste)
*/ */
const submitOps = ( const submitOps = (
currentDoc: CurrentDoc, currentDoc: DocumentContainer,
ops: AnyOperation[], ops: AnyOperation[],
transaction: Transaction transaction: Transaction
) => { ) => {
@ -133,14 +133,14 @@ const submitOps = (
} }
// Check that comments still match text. Will throw error if not. // 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. * Wait for the ShareJS doc to fire an event, then submit the operations.
*/ */
const submitOpsAfterEvent = ( const submitOpsAfterEvent = (
currentDoc: CurrentDoc, currentDoc: DocumentContainer,
eventName: string, eventName: string,
ops: AnyOperation[], ops: AnyOperation[],
transaction: Transaction 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. * Look through the comments stored on cut, and restore those in text that matches the pasted text.
*/ */
export const restoreCommentsOnPaste = ( export const restoreCommentsOnPaste = (
currentDoc: CurrentDoc, currentDoc: DocumentContainer,
transaction: Transaction, transaction: Transaction,
storedComments: StoredComment[] storedComments: StoredComment[]
) => { ) => {
@ -183,18 +183,18 @@ export const restoreCommentsOnPaste = (
* When undoing a change, find comments from the original content and restore them. * When undoing a change, find comments from the original content and restore them.
*/ */
export const restoreDetachedComments = ( export const restoreDetachedComments = (
currentDoc: CurrentDoc, currentDoc: DocumentContainer,
transaction: Transaction, transaction: Transaction,
storedComments: RangeSet<any> storedComments: RangeSet<any>
) => { ) => {
const ops: ChangeOperation[] = [] const ops: CommentOperation[] = []
const cursor = storedComments.iter() const cursor = storedComments.iter()
while (cursor.value) { while (cursor.value) {
const { id } = cursor.value.comment 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 // check that the comment still exists and is detached
if (comment && comment.op.c === '') { if (comment && comment.op.c === '') {

View file

@ -1,9 +1,9 @@
import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state' import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view' import { EditorView, ViewPlugin } from '@codemirror/view'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { CurrentDoc } from '../../../../../types/current-doc'
import { ShareDoc } from '../../../../../types/share-doc' import { ShareDoc } from '../../../../../types/share-doc'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
/* /*
* Integrate CodeMirror 6 with the real-time system, via ShareJS. * 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. * A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
*/ */
export const realtime = ( export const realtime = (
{ currentDoc }: { currentDoc: CurrentDoc }, { currentDoc }: { currentDoc: DocumentContainer },
handleError: (error: Error) => void handleError: (error: Error) => void
) => { ) => {
const realtimePlugin = ViewPlugin.define(view => { const realtimePlugin = ViewPlugin.define(view => {

View file

@ -21,15 +21,14 @@ import {
StoredComment, StoredComment,
} from './changes/comments' } from './changes/comments'
import { invertedEffects } from '@codemirror/commands' import { invertedEffects } from '@codemirror/commands'
import { CurrentDoc } from '../../../../../types/current-doc'
import { Change, DeleteOperation } from '../../../../../types/change' import { Change, DeleteOperation } from '../../../../../types/change'
import { ChangeManager } from './changes/change-manager' import { ChangeManager } from './changes/change-manager'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
import { import {
isChangeOperation, DocumentContainer,
isCommentOperation, RangesTrackerWithResolvedThreadIds,
isDeleteOperation, } from '@/features/ide-react/editor/document-container'
} from '@/utils/operations'
const clearChangesEffect = StateEffect.define() const clearChangesEffect = StateEffect.define()
const buildChangesEffect = StateEffect.define() const buildChangesEffect = StateEffect.define()
@ -46,7 +45,7 @@ const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
}) })
type Options = { type Options = {
currentDoc: CurrentDoc currentDoc: DocumentContainer
loadingThreads: boolean 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 changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
const decorations = [] 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 { id, metadata, op } = change
const from = op.p const from = op.p
@ -273,14 +276,18 @@ const createChangeRange = (change: Change, currentDoc: CurrentDoc) => {
return [calloutWidget.range(from, from), changeWidget.range(from, from)] 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 [] return []
} }
const isChangeOrCommentOperation = const opType = _isCommentOperation ? 'c' : 'i'
isChangeOperation(op) || isCommentOperation(op) const changedText = _isCommentOperation ? op.c : op.i
const opType = isChangeOrCommentOperation ? 'c' : 'i'
const changedText = isChangeOrCommentOperation ? op.c : op.i
const to = from + changedText.length const to = from + changedText.length
// Mark decorations must not be empty // Mark decorations must not be empty

View file

@ -47,7 +47,6 @@ import {
import { setKeybindings } from '../extensions/keybindings' import { setKeybindings } from '../extensions/keybindings'
import { Highlight } from '../../../../../types/highlight' import { Highlight } from '../../../../../types/highlight'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { CurrentDoc } from '../../../../../types/current-doc'
import { useErrorHandler } from 'react-error-boundary' import { useErrorHandler } from 'react-error-boundary'
import { setVisual } from '../extensions/visual/visual' import { setVisual } from '../extensions/visual/visual'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path' 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 isValidTexFile from '@/main/is-valid-tex-file'
import { captureException } from '@/infrastructure/error-reporter' import { captureException } from '@/infrastructure/error-reporter'
import grammarlyExtensionPresent from '@/shared/utils/grammarly' import grammarlyExtensionPresent from '@/shared/utils/grammarly'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
function useCodeMirrorScope(view: EditorView) { function useCodeMirrorScope(view: EditorView) {
const ide = useIdeContext() const ide = useIdeContext()
@ -71,7 +71,9 @@ function useCodeMirrorScope(view: EditorView) {
const [loadingThreads] = useScopeValue<boolean>('loadingThreads') const [loadingThreads] = useScopeValue<boolean>('loadingThreads')
const [currentDoc] = useScopeValue<CurrentDoc | null>('editor.sharejs_doc') const [currentDoc] = useScopeValue<DocumentContainer | null>(
'editor.sharejs_doc'
)
const [docName] = useScopeValue<string>('editor.open_doc_name') const [docName] = useScopeValue<string>('editor.open_doc_name')
const [trackChanges] = useScopeValue<boolean>('editor.trackChanges') const [trackChanges] = useScopeValue<boolean>('editor.trackChanges')

View file

@ -1,5 +1,4 @@
import { import {
ChangeOperation,
CommentOperation, CommentOperation,
DeleteOperation, DeleteOperation,
InsertOperation, InsertOperation,
@ -8,9 +7,7 @@ import {
export const isInsertOperation = (op: Operation): op is InsertOperation => export const isInsertOperation = (op: Operation): op is InsertOperation =>
'i' in op '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 => 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 => export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
'd' in op 'd' in op

View file

@ -1,36 +1,31 @@
import { ThreadId } from './review-panel/review-panel'
import { UserId } from './user'
export interface Operation { export interface Operation {
p: number p: number // position
} }
export interface InsertOperation extends Operation { export interface InsertOperation extends Operation {
i: string i: string // inserted text
t: string
}
export interface ChangeOperation extends Operation {
c: string
t: string
} }
export interface DeleteOperation extends Operation { export interface DeleteOperation extends Operation {
d: string d: string // deleted text
} }
export interface CommentOperation extends Operation { export interface CommentOperation extends Operation {
c: string c: string // comment text
t: ThreadId // thread/comment id
} }
export type NonCommentOperation = export type EditOperation = InsertOperation | DeleteOperation
| InsertOperation
| ChangeOperation
| DeleteOperation
export type AnyOperation = NonCommentOperation | CommentOperation export type AnyOperation = EditOperation | CommentOperation
export type Change<T extends AnyOperation = AnyOperation> = { export type Change<T extends AnyOperation = AnyOperation> = {
id: string id: string
metadata?: { metadata?: {
user_id: string user_id: UserId | null
ts: Date ts: Date
} }
op: T op: T

View file

@ -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<InsertOperation | ChangeOperation | DeleteOperation>[]
comments: Change<CommentOperation>[]
resolvedThreadIds: Record<string, any>
removeCommentId: (id: string) => void
removeChangeIds: (ids: string[]) => void
getChanges: (
ids: string[]
) => Change<InsertOperation | ChangeOperation | DeleteOperation>[]
validate: (text: string) => void
}
attachToCM6: (editor: EditorFacade) => void
detachFromCM6: () => void
submitOp: (op: AnyOperation) => void
getSnapshot: () => string
}