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 = () => { 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(() => {

View file

@ -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. handleLayoutChange({ async: true })
window.setTimeout(handleLayoutChange, 0)
} }
}, [submitting, handleLayoutChange]) }, [submitting, handleLayoutChange])

View file

@ -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])

View file

@ -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,18 +72,11 @@ function positionsEqual(
return isEqual(entryPos1, entryPos2) return isEqual(entryPos1, entryPos2)
} }
const calculateEntryViewPositions = ( function updateEntryPositions(
entryViews: EntryView[], entryView: EntryView,
lineHeight: number, entryTop: number,
calculateTop: (originalTop: number, height: number) => number lineHeight: 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
)
const callout = calculateCalloutPosition( const callout = calculateCalloutPosition(
entryView.entry.screenPos, entryView.entry.screenPos,
entryTop, entryTop,
@ -78,23 +85,118 @@ const calculateEntryViewPositions = (
entryView.positions = { entryView.positions = {
entryTop, entryTop,
callout, callout,
} inViewport: entryView.entry.inViewport,
}
debugConsole.log('ENTRY', { entry: entryView.entry, top })
} }
} }
type PositionedEntriesProps = { function calculateEntryViewPositions(
entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]> entryViews: EntryView[],
contentHeight: number lineHeight: number,
children: React.ReactNode 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 = { function hideOrShowEntries(entryViews: EntryView[]) {
focusedEntryIndex: 0, for (const entryView of entryViews) {
overflowTop: 0, // Completely hide any entry that has no screen position
height: 0, entryView.wrapper.classList.toggle(
positions: [] as EntryPositions[], '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({ 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 // - Re-enable animation and position each entry
// 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 // First, update display for each entry that needs it
for (const entryView of entryViews) { hideOrShowEntries(entryViews)
entryView.wrapper.classList.toggle('rp-entry-hidden', !entryView.visible)
}
// 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 // Calculate positions for all positioned entries, starting by calculating
// entry to put in its desired position and anchor everything else around. // which entry to put in its desired position and anchor everything else
// If there is an explicitly focused entry, use that. // 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 // If the entry from the previous layout has no screen position, fall back
// first visible entry in the list // to the first entry in the list that does.
if (!entryViews[focusedEntryIndex].visible) { if (!entryViews[focusedEntryIndex].hasScreenPos) {
focusedEntryIndex = entryViews.findIndex(view => view.visible) 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 // Put the focused entry as close as possible to where it wants to be
if (focusedEntryView.visible) { 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( updateEntryPositions(focusedEntryView, entryTop, lineHeight)
focusedEntryScreenPos,
entryTop,
lineHeight
)
focusedEntryView.positions = {
entryTop,
callout,
}
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 // Move entries into their initial positions, if animating, avoiding
if (positionsChanged || overflowTopChanged) { // animation until the final animated move
moveEntriesToInitialPosition() 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) { if (isMoveRequired) {
moveToFinalPositions() 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])

View file

@ -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

View file

@ -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) const { visibilityChanged, updateType } =
if (changed) { 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 // panel state is migrated away from Angular. Entries are not animated
scheduleDispatchReviewPanelLayout() // 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 }) dispatchEditorEvent('focus:changed', {
}, 50) 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)) { if (
ignoreGeometryChangesUntil = Date.now() + 50 // ignore changes for 50ms !setsVerticalOverflow &&
} else if ( (geometryChanged || viewportChanged) &&
(update.geometryChanged || update.viewportChanged) && ignoringGeometryChanges
Date.now() < ignoreGeometryChangesUntil
) { ) {
// 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 ( if (changesTopPadding) {
update.docChanged || scheduleDispatchFocusChanged(update.state, 'topPaddingChange')
update.focusChanged || } else if (docChanged) {
update.viewportChanged || scheduleDispatchFocusChanged(update.state, 'edit')
update.selectionSet || } else if (focusChanged || selectionSet) {
update.geometryChanged scheduleDispatchFocusChanged(update.state, 'selectionChange')
) { } else if (viewportChanged && !geometryChanged) {
dispatchFocusChangedEvent(update.state) // 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() {

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 { export function editorVerticalTopPadding(view: EditorView): number {
return view.state.field(overflowPaddingState).top return view.state.field(overflowPaddingState).top
} }

View file

@ -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 const { from, to, empty, updateType } = payload
$scope.$broadcast('editor:focus:changed', from, to, !empty) $scope.$broadcast(
'editor:focus:changed',
from,
to,
!empty,
updateType
)
break break
} }

View file

@ -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) { if (!ref.current) {
isFirstResizeRef.current = false return
} else { }
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. // notifications" that occur if onResize triggers another repaint. The
// The cost of this is that onResize lags one frame behind, but it's // 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

View file

@ -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 & {

View file

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