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
This commit is contained in:
Tim Down 2023-09-05 15:46:13 +01:00 committed by Copybot
parent 0d932a226b
commit 5cce9683fa
11 changed files with 354 additions and 189 deletions

View file

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

View file

@ -111,8 +111,7 @@ function CommentEntry({
useEffect(() => {
if (!submitting) {
// 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])

View file

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

View file

@ -14,6 +14,7 @@ import { isEqual } from 'lodash'
type Positions = {
entryTop: number
callout: { top: number; height: number; inverted: boolean }
inViewport: boolean
}
type EntryView = {
@ -25,13 +26,26 @@ type EntryView = {
layout: HTMLElement
height: number
entry: ReviewPanelEntry
visible: boolean
hasScreenPos: boolean
positions?: Positions
previousPositions?: 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) {
Object.assign(el.style, props)
}
@ -58,18 +72,11 @@ function positionsEqual(
return isEqual(entryPos1, entryPos2)
}
const calculateEntryViewPositions = (
entryViews: EntryView[],
lineHeight: number,
calculateTop: (originalTop: number, height: number) => number
) => {
for (const entryView of entryViews) {
entryView.wrapper.classList.toggle('rp-entry-hidden', !entryView.visible)
if (entryView.visible) {
const entryTop = calculateTop(
entryView.entry.screenPos.y,
entryView.height
)
function updateEntryPositions(
entryView: EntryView,
entryTop: number,
lineHeight: number
) {
const callout = calculateCalloutPosition(
entryView.entry.screenPos,
entryTop,
@ -78,23 +85,118 @@ const calculateEntryViewPositions = (
entryView.positions = {
entryTop,
callout,
}
}
debugConsole.log('ENTRY', { entry: entryView.entry, top })
inViewport: entryView.entry.inViewport,
}
}
type PositionedEntriesProps = {
entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]>
contentHeight: number
children: React.ReactNode
function calculateEntryViewPositions(
entryViews: EntryView[],
lineHeight: number,
calculateTop: (originalTop: number, height: number) => number
) {
for (const entryView of entryViews) {
if (entryView.hasScreenPos) {
const entryTop = calculateTop(
entryView.entry.screenPos.y,
entryView.height
)
updateEntryPositions(entryView, entryTop, lineHeight)
}
}
}
const initialLayoutInfo = {
focusedEntryIndex: 0,
overflowTop: 0,
height: 0,
positions: [] as EntryPositions[],
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
)
}
}
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({
@ -106,10 +208,9 @@ function PositionedEntries({
useReviewPanelValueContext()
const containerRef = useRef<HTMLDivElement | null>(null)
const { reviewPanelOpen } = useLayoutContext()
const animationTimerRef = useRef<number | null>(null)
const previousLayoutInfoRef = useRef(initialLayoutInfo)
const layout = () => {
const layout = (animate = true) => {
const container = containerRef.current
if (!container) {
return
@ -150,7 +251,7 @@ function PositionedEntries({
const previousPositions = previousLayoutInfoRef.current?.positions.find(
pos => pos.entryId === entryId
)?.positions
const visible = Boolean(entry.screenPos)
const hasScreenPos = Boolean(entry.screenPos)
entryViews.push({
entryId,
wrapper,
@ -158,7 +259,7 @@ function PositionedEntries({
box,
callout,
layout: layoutElement,
visible,
hasScreenPos,
height: 0,
entry,
previousPositions,
@ -184,29 +285,26 @@ function PositionedEntries({
// - Measure the height of each entry
// - Move each entry without animation to their original position
// 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
// this case, the best we can do is a write phase, a read phase then a
// final write phase.
// 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
for (const entryView of entryViews) {
if (entryView.visible) {
if (entryView.hasScreenPos) {
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)
if (focusedEntryIndex === -1) {
// There is no explicitly focused entry, so use the focused entry from the
@ -216,30 +314,30 @@ function PositionedEntries({
previousLayoutInfoRef.current.focusedEntryIndex,
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) {
return
}
const focusedEntryView = entryViews[focusedEntryIndex]
if (!focusedEntryView.visible) {
if (!focusedEntryView.hasScreenPos) {
return
}
// If the focused entry has no screenPos, we can't position other
// entryViews relative to it, so we position all other entryViews as
// 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]
const entryViewsBefore = focusedEntryView.visible
const entryViewsBefore = focusedEntryView.hasScreenPos
? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
: []
@ -248,19 +346,11 @@ function PositionedEntries({
let lastEntryBottom = 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 entryTop = Math.max(focusedEntryScreenPos.y, toolbarPaddedHeight)
const callout = calculateCalloutPosition(
focusedEntryScreenPos,
entryTop,
lineHeight
)
focusedEntryView.positions = {
entryTop,
callout,
}
updateEntryPositions(focusedEntryView, entryTop, lineHeight)
lastEntryBottom = entryTop + focusedEntryView.height
firstEntryTop = entryTop
}
@ -292,80 +382,6 @@ function PositionedEntries({
// Calculate the new top overflow
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
// layout
const positions = entryViews.map(
@ -386,10 +402,13 @@ function PositionedEntries({
const height = lastEntryBottom + navPaddedHeight
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
@ -409,8 +428,20 @@ function PositionedEntries({
}
// 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 = {
@ -427,7 +458,7 @@ function PositionedEntries({
if (event.detail.force) {
previousLayoutInfoRef.current = initialLayoutInfo
}
layout()
layout(event.detail.animate)
}
})
@ -437,7 +468,7 @@ function PositionedEntries({
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(() => {
previousLayoutInfoRef.current = initialLayoutInfo
}, [reviewPanelOpen])

View file

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

View file

@ -2,8 +2,9 @@ import { trackChangesAnnotation } from '../realtime'
import { clearChangeMarkers, buildChangeMarkers } from '../track-changes'
import {
setVerticalOverflow,
updateSetsVerticalOverflow,
editorVerticalTopPadding,
updateChangesTopPadding,
updateSetsVerticalOverflow,
} from '../vertical-overflow'
import { EditorSelection, EditorState } from '@codemirror/state'
import { EditorView, ViewUpdate } from '@codemirror/view'
@ -31,23 +32,53 @@ export const dispatchEditorEvent = (type: string, payload?: unknown) => {
}, 0)
}
export const dispatchReviewPanelLayout = (force = false) => {
const dispatchReviewPanelLayoutImmediately = ({
force = false,
animate = true,
} = {}) => {
window.dispatchEvent(
new CustomEvent('review-panel:layout', { detail: { force } })
new CustomEvent('review-panel:layout', { detail: { force, animate } })
)
}
const scheduleDispatchReviewPanelLayout = debounce(
dispatchReviewPanelLayout,
dispatchReviewPanelLayoutImmediately,
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 = {
initialize: () => void
handleUpdate: (update: ViewUpdate) => void
destroy: () => void
}
type UpdateType =
| 'edit'
| 'selectionChange'
| 'geometryChange'
| 'viewportChange'
| 'acceptChanges'
| 'rejectChanges'
| 'trackedChangesChange'
| 'topPaddingChange'
export const createChangeManager = (
view: EditorView,
currentDoc: CurrentDoc
@ -58,7 +89,13 @@ export const createChangeManager = (
*
* 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 { doc } = view.state
@ -88,8 +125,9 @@ export const createChangeManager = (
}
entry.screenPos = { y: y + offsetTop, height, editorPaddingTop }
entry.inViewport = true
} else {
entry.screenPos = null
entry.inViewport = false
}
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': {
const changed = recalculateScreenPositions(payload)
if (changed) {
const { visibilityChanged, updateType } =
recalculateScreenPositions(payload)
if (visibilityChanged) {
dispatchEditorEvent('track-changes:visibility_changed')
}
// 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
// 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
}
@ -321,7 +373,7 @@ export const createChangeManager = (
broadcastChange()
// Dispatch a focus:changed event to force the Angular controller to
// reassemble the list of entries without bulk actions
dispatchFocusChangedEvent(view.state)
scheduleDispatchFocusChanged(view.state, 'acceptChanges')
break
}
@ -330,7 +382,7 @@ export const createChangeManager = (
broadcastChange()
// Dispatch a focus:changed event to force the Angular controller to
// reassemble the list of entries without bulk actions
dispatchFocusChangedEvent(view.state)
scheduleDispatchFocusChanged(view.state, 'rejectChanges')
break
}
@ -368,17 +420,23 @@ export const createChangeManager = (
}
case 'sizes': {
const editorFullContentHeight = view.contentDOM.clientHeight
// the content height and top overflow of the review panel
const { height, overflowTop } = payload
// 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
const topPadding = editorVerticalTopPadding(view)
const bottomPadding = view.documentPadding.bottom
const contentHeight =
editorFullContentHeight - (topPadding + bottomPadding)
const newBottomPadding = height - contentHeight
if (overflowTop !== topPadding || heightDiff !== 0) {
view.dispatch(
setVerticalOverflow({
top: overflowTop,
bottom: heightDiff + view.documentPadding.bottom,
bottom: newBottomPadding,
})
)
}
@ -396,13 +454,27 @@ export const createChangeManager = (
* tell the review panel to update.
*
* @param state object
* @param updateType UpdateType
*/
const dispatchFocusChangedEvent = debounce((state: EditorState) => {
const dispatchFocusChangedImmediately = (
state: EditorState,
updateType: UpdateType
) => {
// TODO: multiple selections?
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.
@ -478,15 +550,25 @@ export const createChangeManager = (
broadcastChange()
},
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()
}
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
// an update to the vertical padding because otherwise it triggers
@ -495,14 +577,25 @@ export const createChangeManager = (
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() {

View file

@ -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 {
return view.state.field(overflowPaddingState).top
}

View file

@ -554,7 +554,14 @@ export default App.controller(
const entries = updateEntries(doc_id)
$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')
})
@ -565,7 +572,13 @@ export default App.controller(
$scope.$on(
'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 entries = getDocEntries(doc_id)
// All selected changes will be added to this array.
@ -655,7 +668,10 @@ export default App.controller(
$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,
// are informed of the changes to entries
@ -1264,6 +1280,11 @@ export default App.controller(
}
}
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')
return $timeout(() => ide.$scope.$broadcast('review-panel:layout'))
})
@ -1346,8 +1367,14 @@ export default App.controller(
}
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
}

View file

@ -36,7 +36,7 @@ function AutoExpandingTextArea({
...rest
}: AutoExpandingTextAreaProps) {
const ref = useRef<HTMLTextAreaElement>(null)
const isFirstResizeRef = useRef(true)
const previousHeightRef = useRef<number | null>(null)
useEffect(() => {
if (!ref.current || !onResize || !('ResizeObserver' in window)) {
@ -46,12 +46,16 @@ function AutoExpandingTextArea({
const resizeObserver = new ResizeObserver(() => {
// Ignore the resize that is triggered when the element is first
// 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
// 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.
// Wrap onResize to prevent extra parameters being passed

View file

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

View file

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