فهرست منبع

Merge pull request #14628 from overleaf/td-review-panel-scroll-jump

Fix scroll jump and improvements to React review panel layout and performance

GitOrigin-RevId: 3123f90abeb1bf9712ba4025a272e13990aebbd9
Tim Down 3 سال پیش
والد
کامیت
5cce9683fa

+ 3 - 3
services/web/frontend/js/features/source-editor/components/review-panel/entries/add-comment-entry.tsx

@@ -27,20 +27,20 @@ function AddCommentEntry({ entryId }: AddCommentEntryProps) {
 
 
   const handleStartNewComment = () => {
   const handleStartNewComment = () => {
     setIsAddingComment(true)
     setIsAddingComment(true)
-    window.setTimeout(handleLayoutChange, 0)
+    handleLayoutChange({ async: true })
   }
   }
 
 
   const handleSubmitNewComment = () => {
   const handleSubmitNewComment = () => {
     submitNewComment(content)
     submitNewComment(content)
     setIsAddingComment(false)
     setIsAddingComment(false)
     setContent('')
     setContent('')
-    window.setTimeout(handleLayoutChange, 0)
+    handleLayoutChange({ async: true })
   }
   }
 
 
   const handleCancelNewComment = () => {
   const handleCancelNewComment = () => {
     setIsAddingComment(false)
     setIsAddingComment(false)
     setContent('')
     setContent('')
-    window.setTimeout(handleLayoutChange, 0)
+    handleLayoutChange({ async: true })
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {

+ 1 - 2
services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx

@@ -111,8 +111,7 @@ function CommentEntry({
   useEffect(() => {
   useEffect(() => {
     if (!submitting) {
     if (!submitting) {
       // Ensure everything is rendered in the DOM before updating the layout.
       // Ensure everything is rendered in the DOM before updating the layout.
-      // Having to use a timeout seems less than ideal.
-      window.setTimeout(handleLayoutChange, 0)
+      handleLayoutChange({ async: true })
     }
     }
   }, [submitting, handleLayoutChange])
   }, [submitting, handleLayoutChange])
 
 

+ 1 - 1
services/web/frontend/js/features/source-editor/components/review-panel/hooks/use-indicator-hover.ts

@@ -30,7 +30,7 @@ function useIndicatorHover() {
         setHoverCoords(null)
         setHoverCoords(null)
         setLayoutSuspended(false)
         setLayoutSuspended(false)
       })
       })
-      handleLayoutChange(true)
+      handleLayoutChange({ force: true })
     }
     }
   }, [handleLayoutChange, layoutToLeft, reviewPanelOpen, setLayoutSuspended])
   }, [handleLayoutChange, layoutToLeft, reviewPanelOpen, setLayoutSuspended])
 
 

+ 169 - 138
services/web/frontend/js/features/source-editor/components/review-panel/positioned-entries.tsx

@@ -14,6 +14,7 @@ import { isEqual } from 'lodash'
 type Positions = {
 type Positions = {
   entryTop: number
   entryTop: number
   callout: { top: number; height: number; inverted: boolean }
   callout: { top: number; height: number; inverted: boolean }
+  inViewport: boolean
 }
 }
 
 
 type EntryView = {
 type EntryView = {
@@ -25,13 +26,26 @@ type EntryView = {
   layout: HTMLElement
   layout: HTMLElement
   height: number
   height: number
   entry: ReviewPanelEntry
   entry: ReviewPanelEntry
-  visible: boolean
+  hasScreenPos: boolean
   positions?: Positions
   positions?: Positions
   previousPositions?: Positions
   previousPositions?: Positions
 }
 }
 
 
 type EntryPositions = Pick<EntryView, 'entryId' | 'positions'>
 type EntryPositions = Pick<EntryView, 'entryId' | 'positions'>
 
 
+type PositionedEntriesProps = {
+  entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]>
+  contentHeight: number
+  children: React.ReactNode
+}
+
+const initialLayoutInfo = {
+  focusedEntryIndex: 0,
+  overflowTop: 0,
+  height: 0,
+  positions: [] as EntryPositions[],
+}
+
 function css(el: HTMLElement, props: React.CSSProperties) {
 function css(el: HTMLElement, props: React.CSSProperties) {
   Object.assign(el.style, props)
   Object.assign(el.style, props)
 }
 }
@@ -58,43 +72,131 @@ function positionsEqual(
   return isEqual(entryPos1, entryPos2)
   return isEqual(entryPos1, entryPos2)
 }
 }
 
 
-const calculateEntryViewPositions = (
+function updateEntryPositions(
+  entryView: EntryView,
+  entryTop: number,
+  lineHeight: number
+) {
+  const callout = calculateCalloutPosition(
+    entryView.entry.screenPos,
+    entryTop,
+    lineHeight
+  )
+  entryView.positions = {
+    entryTop,
+    callout,
+    inViewport: entryView.entry.inViewport,
+  }
+}
+
+function calculateEntryViewPositions(
   entryViews: EntryView[],
   entryViews: EntryView[],
   lineHeight: number,
   lineHeight: number,
   calculateTop: (originalTop: number, height: number) => number
   calculateTop: (originalTop: number, height: number) => number
-) => {
+) {
   for (const entryView of entryViews) {
   for (const entryView of entryViews) {
-    entryView.wrapper.classList.toggle('rp-entry-hidden', !entryView.visible)
-    if (entryView.visible) {
+    if (entryView.hasScreenPos) {
       const entryTop = calculateTop(
       const entryTop = calculateTop(
         entryView.entry.screenPos.y,
         entryView.entry.screenPos.y,
         entryView.height
         entryView.height
       )
       )
-      const callout = calculateCalloutPosition(
-        entryView.entry.screenPos,
-        entryTop,
-        lineHeight
-      )
-      entryView.positions = {
-        entryTop,
-        callout,
-      }
+      updateEntryPositions(entryView, entryTop, lineHeight)
     }
     }
-    debugConsole.log('ENTRY', { entry: entryView.entry, top })
   }
   }
 }
 }
 
 
-type PositionedEntriesProps = {
-  entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]>
-  contentHeight: number
-  children: React.ReactNode
+function hideOrShowEntries(entryViews: EntryView[]) {
+  for (const entryView of entryViews) {
+    // Completely hide any entry that has no screen position
+    entryView.wrapper.classList.toggle(
+      'rp-entry-hidden',
+      !entryView.hasScreenPos
+    )
+  }
 }
 }
 
 
-const initialLayoutInfo = {
-  focusedEntryIndex: 0,
-  overflowTop: 0,
-  height: 0,
-  positions: [] as EntryPositions[],
+function applyEntryTop(entryView: EntryView, top: number) {
+  entryView.box.style.top = top + 'px'
+
+  if (entryView.indicator) {
+    entryView.indicator.style.top = top + 'px'
+  }
+}
+
+function applyEntryVisibility(entryView: EntryView) {
+  // The entry element is invisible by default, to avoid flickering when
+  // positioning for the first time. Here we make sure it becomes visible after
+  // acquiring a screen position.
+  if (entryView.entry.inViewport) {
+    entryView.box.style.visibility = 'visible'
+    entryView.callout.style.visibility = 'visible'
+  }
+}
+
+// Position everything where it was before, taking into account the new top
+// overflow
+function moveEntriesToInitialPosition(
+  entryViews: EntryView[],
+  overflowTop: number
+) {
+  for (const entryView of entryViews) {
+    const { callout: calloutEl, positions } = entryView
+    if (positions) {
+      const { entryTop, callout } = positions
+
+      // Position the main wrapper in its original position, if it had
+      // one, or its new position otherwise
+      const entryTopInitial = entryView.previousPositions
+        ? entryView.previousPositions.entryTop
+        : entryTop
+
+      applyEntryVisibility(entryView)
+      applyEntryTop(entryView, entryTopInitial + overflowTop)
+
+      // Position the callout element in its original position, if it had
+      // one, or its new position otherwise
+      calloutEl.classList.toggle('rp-entry-callout-inverted', callout.inverted)
+      const calloutTopInitial = entryView.previousPositions
+        ? entryView.previousPositions.callout.top
+        : callout.top
+
+      css(calloutEl, {
+        top: calloutTopInitial + overflowTop + 'px',
+        height: callout.height + 'px',
+      })
+    }
+  }
+}
+
+function moveEntriesToFinalPositions(
+  entryViews: EntryView[],
+  overflowTop: number,
+  shouldApplyVisibility: boolean
+) {
+  for (const entryView of entryViews) {
+    const { callout: calloutEl, positions } = entryView
+    if (positions) {
+      const { entryTop, callout } = positions
+
+      if (shouldApplyVisibility) {
+        applyEntryVisibility(entryView)
+      }
+
+      // Position the main wrapper, if it's moved
+      if (entryView.previousPositions?.entryTop !== entryTop) {
+        entryView.box.style.top = entryTop + overflowTop + 'px'
+      }
+
+      if (entryView.indicator) {
+        entryView.indicator.style.top = entryTop + overflowTop + 'px'
+      }
+
+      // Position the callout element
+      if (entryView.previousPositions?.callout.top !== callout.top) {
+        calloutEl.style.top = callout.top + overflowTop + 'px'
+      }
+    }
+  }
 }
 }
 
 
 function PositionedEntries({
 function PositionedEntries({
@@ -106,10 +208,9 @@ function PositionedEntries({
     useReviewPanelValueContext()
     useReviewPanelValueContext()
   const containerRef = useRef<HTMLDivElement | null>(null)
   const containerRef = useRef<HTMLDivElement | null>(null)
   const { reviewPanelOpen } = useLayoutContext()
   const { reviewPanelOpen } = useLayoutContext()
-  const animationTimerRef = useRef<number | null>(null)
   const previousLayoutInfoRef = useRef(initialLayoutInfo)
   const previousLayoutInfoRef = useRef(initialLayoutInfo)
 
 
-  const layout = () => {
+  const layout = (animate = true) => {
     const container = containerRef.current
     const container = containerRef.current
     if (!container) {
     if (!container) {
       return
       return
@@ -150,7 +251,7 @@ function PositionedEntries({
         const previousPositions = previousLayoutInfoRef.current?.positions.find(
         const previousPositions = previousLayoutInfoRef.current?.positions.find(
           pos => pos.entryId === entryId
           pos => pos.entryId === entryId
         )?.positions
         )?.positions
-        const visible = Boolean(entry.screenPos)
+        const hasScreenPos = Boolean(entry.screenPos)
         entryViews.push({
         entryViews.push({
           entryId,
           entryId,
           wrapper,
           wrapper,
@@ -158,7 +259,7 @@ function PositionedEntries({
           box,
           box,
           callout,
           callout,
           layout: layoutElement,
           layout: layoutElement,
-          visible,
+          hasScreenPos,
           height: 0,
           height: 0,
           entry,
           entry,
           previousPositions,
           previousPositions,
@@ -184,29 +285,26 @@ function PositionedEntries({
     // - Measure the height of each entry
     // - Measure the height of each entry
     // - Move each entry without animation to their original position
     // - Move each entry without animation to their original position
     //   relative to the editor content
     //   relative to the editor content
-    // - In the next animation frame, re-enable animation and position each
-    //   entry
+    // - Re-enable animation and position each entry
     //
     //
     // The idea is to batch DOM reads and writes to avoid layout thrashing. In
     // The idea is to batch DOM reads and writes to avoid layout thrashing. In
     // this case, the best we can do is a write phase, a read phase then a
     // this case, the best we can do is a write phase, a read phase then a
     // final write phase.
     // final write phase.
     // See https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/
     // See https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/
 
 
-    // First, update visibility for each entry that needs it
-    for (const entryView of entryViews) {
-      entryView.wrapper.classList.toggle('rp-entry-hidden', !entryView.visible)
-    }
+    // First, update display for each entry that needs it
+    hideOrShowEntries(entryViews)
 
 
     // Next, measure the height of each entry
     // Next, measure the height of each entry
     for (const entryView of entryViews) {
     for (const entryView of entryViews) {
-      if (entryView.visible) {
+      if (entryView.hasScreenPos) {
         entryView.height = entryView.layout.offsetHeight
         entryView.height = entryView.layout.offsetHeight
       }
       }
     }
     }
 
 
-    // Calculate positions for all visible entries, starting by calculating the
-    // entry to put in its desired position and anchor everything else around.
-    // If there is an explicitly focused entry, use that.
+    // Calculate positions for all positioned entries, starting by calculating
+    // which entry to put in its desired position and anchor everything else
+    // around. If there is an explicitly focused entry, use that.
     let focusedEntryIndex = entryViews.findIndex(view => view.entry.focused)
     let focusedEntryIndex = entryViews.findIndex(view => view.entry.focused)
     if (focusedEntryIndex === -1) {
     if (focusedEntryIndex === -1) {
       // There is no explicitly focused entry, so use the focused entry from the
       // There is no explicitly focused entry, so use the focused entry from the
@@ -216,30 +314,30 @@ function PositionedEntries({
         previousLayoutInfoRef.current.focusedEntryIndex,
         previousLayoutInfoRef.current.focusedEntryIndex,
         entryViews.length - 1
         entryViews.length - 1
       )
       )
-      // If the entry from the previous layout is not visible, fall back to the
-      // first visible entry in the list
-      if (!entryViews[focusedEntryIndex].visible) {
-        focusedEntryIndex = entryViews.findIndex(view => view.visible)
+      // If the entry from the previous layout has no screen position, fall back
+      // to the first entry in the list that does.
+      if (!entryViews[focusedEntryIndex].hasScreenPos) {
+        focusedEntryIndex = entryViews.findIndex(view => view.hasScreenPos)
       }
       }
     }
     }
 
 
-    // If there is no visible entry, bail out
+    // If there is no entry with a screen position, bail out
     if (focusedEntryIndex === -1) {
     if (focusedEntryIndex === -1) {
       return
       return
     }
     }
 
 
     const focusedEntryView = entryViews[focusedEntryIndex]
     const focusedEntryView = entryViews[focusedEntryIndex]
-    if (!focusedEntryView.visible) {
+    if (!focusedEntryView.hasScreenPos) {
       return
       return
     }
     }
 
 
     // If the focused entry has no screenPos, we can't position other
     // If the focused entry has no screenPos, we can't position other
     // entryViews relative to it, so we position all other entryViews as
     // entryViews relative to it, so we position all other entryViews as
     // though the focused entry is at the top and the rest follow it
     // though the focused entry is at the top and the rest follow it
-    const entryViewsAfter = focusedEntryView.visible
+    const entryViewsAfter = focusedEntryView.hasScreenPos
       ? entryViews.slice(focusedEntryIndex + 1)
       ? entryViews.slice(focusedEntryIndex + 1)
       : [...entryViews]
       : [...entryViews]
-    const entryViewsBefore = focusedEntryView.visible
+    const entryViewsBefore = focusedEntryView.hasScreenPos
       ? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
       ? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
       : []
       : []
 
 
@@ -248,19 +346,11 @@ function PositionedEntries({
     let lastEntryBottom = 0
     let lastEntryBottom = 0
     let firstEntryTop = 0
     let firstEntryTop = 0
 
 
-    // Put the focused entry as close to where it wants to be as possible
-    if (focusedEntryView.visible) {
+    // Put the focused entry as close as possible to where it wants to be
+    if (focusedEntryView.hasScreenPos) {
       const focusedEntryScreenPos = focusedEntryView.entry.screenPos
       const focusedEntryScreenPos = focusedEntryView.entry.screenPos
       const entryTop = Math.max(focusedEntryScreenPos.y, toolbarPaddedHeight)
       const entryTop = Math.max(focusedEntryScreenPos.y, toolbarPaddedHeight)
-      const callout = calculateCalloutPosition(
-        focusedEntryScreenPos,
-        entryTop,
-        lineHeight
-      )
-      focusedEntryView.positions = {
-        entryTop,
-        callout,
-      }
+      updateEntryPositions(focusedEntryView, entryTop, lineHeight)
       lastEntryBottom = entryTop + focusedEntryView.height
       lastEntryBottom = entryTop + focusedEntryView.height
       firstEntryTop = entryTop
       firstEntryTop = entryTop
     }
     }
@@ -292,80 +382,6 @@ function PositionedEntries({
     // Calculate the new top overflow
     // Calculate the new top overflow
     const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
     const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
 
 
-    // Position everything where it was before, taking into account the new top
-    // overflow
-    const moveEntriesToInitialPosition = () => {
-      // Prevent CSS animation of position for this phase
-      container.classList.add('no-animate')
-      for (const entryView of entryViews) {
-        const { callout: calloutEl, positions } = entryView
-        if (positions) {
-          const { entryTop, callout } = positions
-
-          // Position the main wrapper in its original position, if it had
-          // one, or its new position otherwise
-          const entryTopInitial = entryView.previousPositions
-            ? entryView.previousPositions.entryTop
-            : entryTop
-
-          css(entryView.box, {
-            top: entryTopInitial + overflowTop + 'px',
-            // The entry element is invisible by default, to avoid flickering
-            // when positioning for the first time. Here we make sure it becomes
-            // visible after having a "top" value.
-            visibility: 'visible',
-          })
-
-          if (entryView.indicator) {
-            entryView.indicator.style.top = entryTopInitial + overflowTop + 'px'
-          }
-
-          // Position the callout element in its original position, if it had
-          // one, or its new position otherwise
-          calloutEl.classList.toggle(
-            'rp-entry-callout-inverted',
-            callout.inverted
-          )
-          const calloutTopInitial = entryView.previousPositions
-            ? entryView.previousPositions.callout.top
-            : callout.top
-
-          css(calloutEl, {
-            top: calloutTopInitial + overflowTop + 'px',
-            height: callout.height + 'px',
-          })
-        }
-      }
-    }
-
-    const moveToFinalPositions = () => {
-      // Re-enable CSS animation of position for this phase
-      container.classList.remove('no-animate')
-
-      for (const entryView of entryViews) {
-        const { callout: calloutEl, positions } = entryView
-        if (positions) {
-          const { entryTop, callout } = positions
-
-          // Position the main wrapper, if it's moved
-          if (entryView.previousPositions?.entryTop !== entryTop) {
-            entryView.box.style.top = entryTop + overflowTop + 'px'
-          }
-
-          if (entryView.indicator) {
-            entryView.indicator.style.top = entryTop + overflowTop + 'px'
-          }
-
-          // Position the callout element
-          if (entryView.previousPositions?.callout.top !== callout.top) {
-            calloutEl.style.top = callout.top + overflowTop + 'px'
-          }
-        }
-      }
-
-      animationTimerRef.current = null
-    }
-
     // Check whether the positions of any entry have changed since the last
     // Check whether the positions of any entry have changed since the last
     // layout
     // layout
     const positions = entryViews.map(
     const positions = entryViews.map(
@@ -386,10 +402,13 @@ function PositionedEntries({
 
 
     const height = lastEntryBottom + navPaddedHeight
     const height = lastEntryBottom + navPaddedHeight
     const heightChanged = height !== previousLayoutInfoRef.current.height
     const heightChanged = height !== previousLayoutInfoRef.current.height
+    const isMoveRequired = positionsChanged || overflowTopChanged
 
 
-    // Move entries into their initial positions
-    if (positionsChanged || overflowTopChanged) {
-      moveEntriesToInitialPosition()
+    // Move entries into their initial positions, if animating, avoiding
+    // animation until the final animated move
+    if (animate && isMoveRequired) {
+      container.classList.add('no-animate')
+      moveEntriesToInitialPosition(entryViews, overflowTop)
     }
     }
 
 
     // Inform the editor of the new top overflow and/or height if either has
     // Inform the editor of the new top overflow and/or height if either has
@@ -409,8 +428,20 @@ function PositionedEntries({
     }
     }
 
 
     // Do the final move
     // Do the final move
-    if (positionsChanged || overflowTopChanged) {
-      moveToFinalPositions()
+    if (isMoveRequired) {
+      if (animate) {
+        container.classList.remove('no-animate')
+        moveEntriesToFinalPositions(entryViews, overflowTop, false)
+      } else {
+        container.classList.add('no-animate')
+        moveEntriesToFinalPositions(entryViews, overflowTop, true)
+
+        // Force reflow now to ensure that entries are moved without animation
+        // eslint-disable-next-line no-void
+        void container.offsetHeight
+
+        container.classList.remove('no-animate')
+      }
     }
     }
 
 
     previousLayoutInfoRef.current = {
     previousLayoutInfoRef.current = {
@@ -427,7 +458,7 @@ function PositionedEntries({
       if (event.detail.force) {
       if (event.detail.force) {
         previousLayoutInfoRef.current = initialLayoutInfo
         previousLayoutInfoRef.current = initialLayoutInfo
       }
       }
-      layout()
+      layout(event.detail.animate)
     }
     }
   })
   })
 
 
@@ -437,7 +468,7 @@ function PositionedEntries({
     dispatchReviewPanelLayout()
     dispatchReviewPanelLayout()
   }, [])
   }, [])
 
 
-  // Ensure layout is performed after opening or closing the review panel
+  // Ensure a full layout is performed after opening or closing the review panel
   useEffect(() => {
   useEffect(() => {
     previousLayoutInfoRef.current = initialLayoutInfo
     previousLayoutInfoRef.current = initialLayoutInfo
   }, [reviewPanelOpen])
   }, [reviewPanelOpen])

+ 4 - 1
services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts

@@ -11,6 +11,7 @@ import {
   DocId,
   DocId,
   MainDocument,
   MainDocument,
 } from '../../../../../../../types/project-settings'
 } from '../../../../../../../types/project-settings'
+import { dispatchReviewPanelLayout } from '../../../extensions/changes/change-manager'
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 export interface ReviewPanelState {
 export interface ReviewPanelState {
@@ -49,7 +50,9 @@ export interface ReviewPanelState {
   }
   }
   updaterFns: {
   updaterFns: {
     handleSetSubview: (subView: SubView) => void
     handleSetSubview: (subView: SubView) => void
-    handleLayoutChange: (force?: boolean) => void
+    handleLayoutChange: (
+      ...args: Parameters<typeof dispatchReviewPanelLayout>
+    ) => void
     gotoEntry: (docId: DocId, entryOffset: number) => void
     gotoEntry: (docId: DocId, entryOffset: number) => void
     resolveComment: (docId: DocId, entryId: ThreadId) => void
     resolveComment: (docId: DocId, entryId: ThreadId) => void
     deleteComment: (threadId: ThreadId, commentId: CommentId) => void
     deleteComment: (threadId: ThreadId, commentId: CommentId) => void

+ 125 - 32
services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts

@@ -2,8 +2,9 @@ import { trackChangesAnnotation } from '../realtime'
 import { clearChangeMarkers, buildChangeMarkers } from '../track-changes'
 import { clearChangeMarkers, buildChangeMarkers } from '../track-changes'
 import {
 import {
   setVerticalOverflow,
   setVerticalOverflow,
-  updateSetsVerticalOverflow,
   editorVerticalTopPadding,
   editorVerticalTopPadding,
+  updateChangesTopPadding,
+  updateSetsVerticalOverflow,
 } 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'
@@ -31,23 +32,53 @@ export const dispatchEditorEvent = (type: string, payload?: unknown) => {
   }, 0)
   }, 0)
 }
 }
 
 
-export const dispatchReviewPanelLayout = (force = false) => {
+const dispatchReviewPanelLayoutImmediately = ({
+  force = false,
+  animate = true,
+} = {}) => {
   window.dispatchEvent(
   window.dispatchEvent(
-    new CustomEvent('review-panel:layout', { detail: { force } })
+    new CustomEvent('review-panel:layout', { detail: { force, animate } })
   )
   )
 }
 }
 
 
 const scheduleDispatchReviewPanelLayout = debounce(
 const scheduleDispatchReviewPanelLayout = debounce(
-  dispatchReviewPanelLayout,
+  dispatchReviewPanelLayoutImmediately,
   10
   10
 )
 )
 
 
+/**
+ * @param force If true, forces the entries to be repositioned
+ * @param animate
+ * @param async If true, calls are briefly delayed and debounced
+ */
+export const dispatchReviewPanelLayout = ({
+  force = false,
+  animate = true,
+  async = false,
+} = {}) => {
+  if (async) {
+    scheduleDispatchReviewPanelLayout({ force, animate })
+  } else {
+    dispatchReviewPanelLayoutImmediately({ force, animate })
+  }
+}
+
 export type ChangeManager = {
 export type ChangeManager = {
   initialize: () => void
   initialize: () => void
   handleUpdate: (update: ViewUpdate) => void
   handleUpdate: (update: ViewUpdate) => void
   destroy: () => void
   destroy: () => void
 }
 }
 
 
+type UpdateType =
+  | 'edit'
+  | 'selectionChange'
+  | 'geometryChange'
+  | 'viewportChange'
+  | 'acceptChanges'
+  | 'rejectChanges'
+  | 'trackedChangesChange'
+  | 'topPaddingChange'
+
 export const createChangeManager = (
 export const createChangeManager = (
   view: EditorView,
   view: EditorView,
   currentDoc: CurrentDoc
   currentDoc: CurrentDoc
@@ -58,7 +89,13 @@ export const createChangeManager = (
    *
    *
    * Returns a boolean indicating whether the visibility of any entry has changed
    * Returns a boolean indicating whether the visibility of any entry has changed
    */
    */
-  const recalculateScreenPositions = (entries?: Record<string, any>) => {
+  const recalculateScreenPositions = ({
+    entries,
+    updateType,
+  }: {
+    entries?: Record<string, any>
+    updateType: UpdateType
+  }) => {
     const contentRect = view.contentDOM.getBoundingClientRect()
     const contentRect = view.contentDOM.getBoundingClientRect()
 
 
     const { doc } = view.state
     const { doc } = view.state
@@ -88,8 +125,9 @@ export const createChangeManager = (
         }
         }
 
 
         entry.screenPos = { y: y + offsetTop, height, editorPaddingTop }
         entry.screenPos = { y: y + offsetTop, height, editorPaddingTop }
+        entry.inViewport = true
       } else {
       } else {
-        entry.screenPos = null
+        entry.inViewport = false
       }
       }
 
 
       if (allVisible) {
       if (allVisible) {
@@ -114,7 +152,7 @@ export const createChangeManager = (
       }
       }
     }
     }
 
 
-    return visibilityChanged
+    return { visibilityChanged, updateType }
   }
   }
 
 
   /**
   /**
@@ -303,15 +341,29 @@ export const createChangeManager = (
       }
       }
 
 
       case 'recalculate-screen-positions': {
       case 'recalculate-screen-positions': {
-        const changed = recalculateScreenPositions(payload)
-        if (changed) {
+        const { visibilityChanged, updateType } =
+          recalculateScreenPositions(payload)
+        if (visibilityChanged) {
           dispatchEditorEvent('track-changes:visibility_changed')
           dispatchEditorEvent('track-changes:visibility_changed')
         }
         }
         // Ensure the layout is updated once the review panel entries have
         // Ensure the layout is updated once the review panel entries have
         // updated in the React review panel. The use of a timeout is bad but
         // updated in the React review panel. The use of a timeout is bad but
         // the timings are a bit of a mess and will be improved when the review
         // the timings are a bit of a mess and will be improved when the review
-        // panel state is migrated away from Angular
-        scheduleDispatchReviewPanelLayout()
+        // panel state is migrated away from Angular. Entries are not animated
+        // into position when scrolling, or when the editor geometry changes
+        // (e.g. because the window has been resized), or when the top padding
+        // is adjusted
+        const nonAnimatingUpdateTypes: UpdateType[] = [
+          'viewportChange',
+          'geometryChange',
+          'topPaddingChange',
+        ]
+        const animate = !nonAnimatingUpdateTypes.includes(updateType)
+        dispatchReviewPanelLayout({
+          async: true,
+          animate,
+          force: false, // updateType === 'geometryChange',
+        })
         break
         break
       }
       }
 
 
@@ -321,7 +373,7 @@ export const createChangeManager = (
         broadcastChange()
         broadcastChange()
         // Dispatch a focus:changed event to force the Angular controller to
         // Dispatch a focus:changed event to force the Angular controller to
         // reassemble the list of entries without bulk actions
         // reassemble the list of entries without bulk actions
-        dispatchFocusChangedEvent(view.state)
+        scheduleDispatchFocusChanged(view.state, 'acceptChanges')
         break
         break
       }
       }
 
 
@@ -330,7 +382,7 @@ export const createChangeManager = (
         broadcastChange()
         broadcastChange()
         // Dispatch a focus:changed event to force the Angular controller to
         // Dispatch a focus:changed event to force the Angular controller to
         // reassemble the list of entries without bulk actions
         // reassemble the list of entries without bulk actions
-        dispatchFocusChangedEvent(view.state)
+        scheduleDispatchFocusChanged(view.state, 'rejectChanges')
         break
         break
       }
       }
 
 
@@ -368,17 +420,23 @@ export const createChangeManager = (
       }
       }
 
 
       case 'sizes': {
       case 'sizes': {
+        const editorFullContentHeight = view.contentDOM.clientHeight
         // the content height and top overflow of the review panel
         // the content height and top overflow of the review panel
         const { height, overflowTop } = payload
         const { height, overflowTop } = payload
         // the difference between the review panel height and the editor content height
         // the difference between the review panel height and the editor content height
-        const heightDiff = height + overflowTop - view.contentDOM.clientHeight
+        const heightDiff = height + overflowTop - editorFullContentHeight
         // the height of the block added at the top of the editor to match the review panel
         // the height of the block added at the top of the editor to match the review panel
         const topPadding = editorVerticalTopPadding(view)
         const topPadding = editorVerticalTopPadding(view)
+        const bottomPadding = view.documentPadding.bottom
+        const contentHeight =
+          editorFullContentHeight - (topPadding + bottomPadding)
+        const newBottomPadding = height - contentHeight
+
         if (overflowTop !== topPadding || heightDiff !== 0) {
         if (overflowTop !== topPadding || heightDiff !== 0) {
           view.dispatch(
           view.dispatch(
             setVerticalOverflow({
             setVerticalOverflow({
               top: overflowTop,
               top: overflowTop,
-              bottom: heightDiff + view.documentPadding.bottom,
+              bottom: newBottomPadding,
             })
             })
           )
           )
         }
         }
@@ -396,13 +454,27 @@ export const createChangeManager = (
    * tell the review panel to update.
    * tell the review panel to update.
    *
    *
    * @param state object
    * @param state object
+   * @param updateType UpdateType
    */
    */
-  const dispatchFocusChangedEvent = debounce((state: EditorState) => {
+  const dispatchFocusChangedImmediately = (
+    state: EditorState,
+    updateType: UpdateType
+  ) => {
     // TODO: multiple selections?
     // TODO: multiple selections?
     const { from, to, empty } = state.selection.main
     const { from, to, empty } = state.selection.main
 
 
-    dispatchEditorEvent('focus:changed', { from, to, empty })
-  }, 50)
+    dispatchEditorEvent('focus:changed', {
+      from,
+      to,
+      empty,
+      updateType,
+    })
+  }
+
+  const scheduleDispatchFocusChanged = debounce(
+    dispatchFocusChangedImmediately,
+    50
+  )
 
 
   /**
   /**
    * When the editor is scrolled, tell the review panel so it can scroll in sync.
    * When the editor is scrolled, tell the review panel so it can scroll in sync.
@@ -478,15 +550,25 @@ export const createChangeManager = (
       broadcastChange()
       broadcastChange()
     },
     },
     handleUpdate(update: ViewUpdate) {
     handleUpdate(update: ViewUpdate) {
-      if (update.geometryChanged && !update.docChanged) {
+      const changesTopPadding = updateChangesTopPadding(update)
+      const {
+        geometryChanged,
+        viewportChanged,
+        docChanged,
+        focusChanged,
+        selectionSet,
+      } = update
+      const setsVerticalOverflow = updateSetsVerticalOverflow(update)
+      const ignoringGeometryChanges = Date.now() < ignoreGeometryChangesUntil
+
+      if (geometryChanged && !docChanged && !ignoringGeometryChanges) {
         broadcastChange()
         broadcastChange()
       }
       }
 
 
-      if (updateSetsVerticalOverflow(update)) {
-        ignoreGeometryChangesUntil = Date.now() + 50 // ignore changes for 50ms
-      } else if (
-        (update.geometryChanged || update.viewportChanged) &&
-        Date.now() < ignoreGeometryChangesUntil
+      if (
+        !setsVerticalOverflow &&
+        (geometryChanged || viewportChanged) &&
+        ignoringGeometryChanges
       ) {
       ) {
         // Ignore a change to the editor geometry or viewport that occurs immediately after
         // Ignore a change to the editor geometry or viewport that occurs immediately after
         // an update to the vertical padding because otherwise it triggers
         // an update to the vertical padding because otherwise it triggers
@@ -495,14 +577,25 @@ export const createChangeManager = (
         return
         return
       }
       }
 
 
-      if (
-        update.docChanged ||
-        update.focusChanged ||
-        update.viewportChanged ||
-        update.selectionSet ||
-        update.geometryChanged
-      ) {
-        dispatchFocusChangedEvent(update.state)
+      if (changesTopPadding) {
+        scheduleDispatchFocusChanged(update.state, 'topPaddingChange')
+      } else if (docChanged) {
+        scheduleDispatchFocusChanged(update.state, 'edit')
+      } else if (focusChanged || selectionSet) {
+        scheduleDispatchFocusChanged(update.state, 'selectionChange')
+      } else if (viewportChanged && !geometryChanged) {
+        // It's better to respond immediately to a viewport change, which
+        // happens when scrolling, and have previously unpositioned entries
+        // appear immediately rather than risk a delay due to debouncing
+        dispatchFocusChangedImmediately(update.state, 'viewportChange')
+      } else if (geometryChanged) {
+        scheduleDispatchFocusChanged(update.state, 'geometryChange')
+      }
+
+      // Wait until after updating the review panel layout before starting the
+      // interval during which to ignore geometry update
+      if (setsVerticalOverflow) {
+        ignoreGeometryChangesUntil = Date.now() + 50
       }
       }
     },
     },
     destroy() {
     destroy() {

+ 7 - 0
services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts

@@ -235,6 +235,13 @@ export function updateSetsVerticalOverflow(update: ViewUpdate): boolean {
   })
   })
 }
 }
 
 
+export function updateChangesTopPadding(update: ViewUpdate): boolean {
+  return (
+    update.state.field(overflowPaddingState).top !==
+    update.startState.field(overflowPaddingState).top
+  )
+}
+
 export function editorVerticalTopPadding(view: EditorView): number {
 export function editorVerticalTopPadding(view: EditorView): number {
   return view.state.field(overflowPaddingState).top
   return view.state.field(overflowPaddingState).top
 }
 }

+ 32 - 5
services/web/frontend/js/ide/review-panel/controllers/ReviewPanelController.js

@@ -554,7 +554,14 @@ export default App.controller(
       const entries = updateEntries(doc_id)
       const entries = updateEntries(doc_id)
 
 
       $scope.$broadcast('review-panel:recalculate-screen-positions')
       $scope.$broadcast('review-panel:recalculate-screen-positions')
-      dispatchReviewPanelEvent('recalculate-screen-positions', entries)
+      dispatchReviewPanelEvent('recalculate-screen-positions', {
+        entries,
+        updateType: 'trackedChangesChange',
+      })
+
+      // Ensure that watchers, such as the React-based review panel component,
+      // are informed of the changes to entries
+      ide.$scope.$apply()
 
 
       return ide.$scope.$broadcast('review-panel:layout')
       return ide.$scope.$broadcast('review-panel:layout')
     })
     })
@@ -565,7 +572,13 @@ export default App.controller(
 
 
     $scope.$on(
     $scope.$on(
       'editor:focus:changed',
       'editor:focus:changed',
-      function (e, selection_offset_start, selection_offset_end, selection) {
+      function (
+        e,
+        selection_offset_start,
+        selection_offset_end,
+        selection,
+        updateType = null
+      ) {
         const doc_id = $scope.editor.open_doc_id
         const doc_id = $scope.editor.open_doc_id
         const entries = getDocEntries(doc_id)
         const entries = getDocEntries(doc_id)
         // All selected changes will be added to this array.
         // All selected changes will be added to this array.
@@ -655,7 +668,10 @@ export default App.controller(
 
 
         $scope.$broadcast('review-panel:recalculate-screen-positions')
         $scope.$broadcast('review-panel:recalculate-screen-positions')
 
 
-        dispatchReviewPanelEvent('recalculate-screen-positions', entries)
+        dispatchReviewPanelEvent('recalculate-screen-positions', {
+          entries,
+          updateType,
+        })
 
 
         // Ensure that watchers, such as the React-based review panel component,
         // Ensure that watchers, such as the React-based review panel component,
         // are informed of the changes to entries
         // are informed of the changes to entries
@@ -1264,6 +1280,11 @@ export default App.controller(
             }
             }
           }
           }
           ide.$scope.reviewPanel.commentThreads = threads
           ide.$scope.reviewPanel.commentThreads = threads
+          // Update entries so that the view has up-to-date information about
+          // the entries when handling the loaded_threads events, which avoids
+          // thrashing
+          updateEntries($scope.editor.open_doc_id)
+
           dispatchReviewPanelEvent('loaded_threads')
           dispatchReviewPanelEvent('loaded_threads')
           return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
           return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
         })
         })
@@ -1346,8 +1367,14 @@ export default App.controller(
         }
         }
 
 
         case 'focus:changed': {
         case 'focus:changed': {
-          const { from, to, empty } = payload
-          $scope.$broadcast('editor:focus:changed', from, to, !empty)
+          const { from, to, empty, updateType } = payload
+          $scope.$broadcast(
+            'editor:focus:changed',
+            from,
+            to,
+            !empty,
+            updateType
+          )
           break
           break
         }
         }
 
 

+ 10 - 6
services/web/frontend/js/shared/components/auto-expanding-text-area.tsx

@@ -36,7 +36,7 @@ function AutoExpandingTextArea({
   ...rest
   ...rest
 }: AutoExpandingTextAreaProps) {
 }: AutoExpandingTextAreaProps) {
   const ref = useRef<HTMLTextAreaElement>(null)
   const ref = useRef<HTMLTextAreaElement>(null)
-  const isFirstResizeRef = useRef(true)
+  const previousHeightRef = useRef<number | null>(null)
 
 
   useEffect(() => {
   useEffect(() => {
     if (!ref.current || !onResize || !('ResizeObserver' in window)) {
     if (!ref.current || !onResize || !('ResizeObserver' in window)) {
@@ -46,12 +46,16 @@ function AutoExpandingTextArea({
     const resizeObserver = new ResizeObserver(() => {
     const resizeObserver = new ResizeObserver(() => {
       // Ignore the resize that is triggered when the element is first
       // Ignore the resize that is triggered when the element is first
       // inserted into the DOM
       // inserted into the DOM
-      if (isFirstResizeRef.current) {
-        isFirstResizeRef.current = false
-      } else {
+      if (!ref.current) {
+        return
+      }
+      const newHeight = ref.current.offsetHeight
+      const heightChanged = newHeight !== previousHeightRef.current
+      previousHeightRef.current = newHeight
+      if (heightChanged) {
         // Prevent errors like "ResizeObserver loop completed with undelivered
         // Prevent errors like "ResizeObserver loop completed with undelivered
-        // notifications" that occur if onResize does something complicated.
-        // The cost of this is that onResize lags one frame behind, but it's
+        // notifications" that occur if onResize triggers another repaint. The
+        // cost of this is that onResize lags one frame behind, but it's
         // unlikely to matter.
         // unlikely to matter.
 
 
         // Wrap onResize to prevent extra parameters being passed
         // Wrap onResize to prevent extra parameters being passed

+ 1 - 1
services/web/frontend/stylesheets/app/editor/review-panel.less

@@ -1275,7 +1275,7 @@ button when (@is-overleaf-light = true) {
 
 
   .rp-entry-list-react {
   .rp-entry-list-react {
     position: relative;
     position: relative;
-    overflow-x: hidden;
+    overflow: hidden;
   }
   }
 
 
   .rp-state-current-file & {
   .rp-state-current-file & {

+ 1 - 0
services/web/types/review-panel/entry.ts

@@ -11,6 +11,7 @@ interface ReviewPanelBaseEntry {
   offset: number
   offset: number
   screenPos: ReviewPanelEntryScreenPos
   screenPos: ReviewPanelEntryScreenPos
   visible: boolean
   visible: boolean
+  inViewport: boolean
 }
 }
 
 
 interface ReviewPanelInsertOrDeleteEntry {
 interface ReviewPanelInsertOrDeleteEntry {