|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useEffect, useRef } from 'react'
|
|
|
+import { useEffect, useLayoutEffect, useRef, useCallback } from 'react'
|
|
|
import { useLayoutContext } from '../../../../shared/context/layout-context'
|
|
|
import {
|
|
|
ReviewPanelEntry,
|
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
} from '../../../../../../types/review-panel/entry'
|
|
|
import { debugConsole } from '../../../../utils/debugging'
|
|
|
import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
|
|
|
-import useEventListener from '../../../../shared/hooks/use-event-listener'
|
|
|
import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
|
|
|
import { dispatchReviewPanelLayout } from '../../extensions/changes/change-manager'
|
|
|
import { isEqual } from 'lodash'
|
|
|
@@ -214,255 +213,269 @@ function PositionedEntries({
|
|
|
previousLayoutInfoRef.current = initialLayoutInfo
|
|
|
}
|
|
|
|
|
|
- const layout = (animate = true) => {
|
|
|
- const container = containerRef.current
|
|
|
- if (!container) {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const padding = reviewPanelOpen ? 8 : 4
|
|
|
- const toolbarPaddedHeight = reviewPanelOpen ? toolbarHeight + 6 : 0
|
|
|
- const navPaddedHeight = reviewPanelOpen ? navHeight + 4 : 0
|
|
|
-
|
|
|
- // Create a list of entry views, typing together DOM elements and model.
|
|
|
- // No measuring or style change is done at this point.
|
|
|
- const entryViews: EntryView[] = []
|
|
|
-
|
|
|
- // TODO: Look into tying the entry to the DOM element without going via a DOM data attribute
|
|
|
- for (const wrapper of container.querySelectorAll<HTMLElement>(
|
|
|
- '.rp-entry-wrapper'
|
|
|
- )) {
|
|
|
- const entryId = wrapper.dataset.entryId as
|
|
|
- | EntryView['entryId']
|
|
|
- | undefined
|
|
|
- if (!entryId) {
|
|
|
- throw new Error('Could not find an entry ID')
|
|
|
- }
|
|
|
-
|
|
|
- const entry = entries.find(value => value[0] === entryId)?.[1]
|
|
|
- if (!entry) {
|
|
|
- throw new Error(`Could not find an entry for ID ${entryId}`)
|
|
|
+ const layout = useCallback(
|
|
|
+ (animate = true) => {
|
|
|
+ const container = containerRef.current
|
|
|
+ if (!container) {
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
- const indicator = wrapper.querySelector<HTMLElement>(
|
|
|
- '.rp-entry-indicator'
|
|
|
- )
|
|
|
- const box = wrapper.querySelector<HTMLElement>('.rp-entry')
|
|
|
- const callout = wrapper.querySelector<HTMLElement>('.rp-entry-callout')
|
|
|
- const layoutElement = reviewPanelOpen ? box : indicator
|
|
|
-
|
|
|
- if (box && callout && layoutElement) {
|
|
|
- const previousPositions = previousLayoutInfoRef.current?.positions.find(
|
|
|
- pos => pos.entryId === entryId
|
|
|
- )?.positions
|
|
|
- const hasScreenPos = Boolean(entry.screenPos)
|
|
|
- entryViews.push({
|
|
|
- entryId,
|
|
|
- wrapper,
|
|
|
- indicator,
|
|
|
- box,
|
|
|
- callout,
|
|
|
- layout: layoutElement,
|
|
|
- hasScreenPos,
|
|
|
- height: 0,
|
|
|
- entry,
|
|
|
- previousPositions,
|
|
|
- })
|
|
|
- } else {
|
|
|
- debugConsole.log(
|
|
|
- 'Entry wrapper is missing indicator, box or callout, so ignoring',
|
|
|
- wrapper
|
|
|
+ const padding = reviewPanelOpen ? 8 : 4
|
|
|
+ const toolbarPaddedHeight = reviewPanelOpen ? toolbarHeight + 6 : 0
|
|
|
+ const navPaddedHeight = reviewPanelOpen ? navHeight + 4 : 0
|
|
|
+
|
|
|
+ // Create a list of entry views, typing together DOM elements and model.
|
|
|
+ // No measuring or style change is done at this point.
|
|
|
+ const entryViews: EntryView[] = []
|
|
|
+
|
|
|
+ // TODO: Look into tying the entry to the DOM element without going via a DOM data attribute
|
|
|
+ for (const wrapper of container.querySelectorAll<HTMLElement>(
|
|
|
+ '.rp-entry-wrapper'
|
|
|
+ )) {
|
|
|
+ const entryId = wrapper.dataset.entryId as
|
|
|
+ | EntryView['entryId']
|
|
|
+ | undefined
|
|
|
+ if (!entryId) {
|
|
|
+ throw new Error('Could not find an entry ID')
|
|
|
+ }
|
|
|
+
|
|
|
+ const entry = entries.find(value => value[0] === entryId)?.[1]
|
|
|
+ if (!entry) {
|
|
|
+ throw new Error(`Could not find an entry for ID ${entryId}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const indicator = wrapper.querySelector<HTMLElement>(
|
|
|
+ '.rp-entry-indicator'
|
|
|
)
|
|
|
+ const box = wrapper.querySelector<HTMLElement>('.rp-entry')
|
|
|
+ const callout = wrapper.querySelector<HTMLElement>('.rp-entry-callout')
|
|
|
+ const layoutElement = reviewPanelOpen ? box : indicator
|
|
|
+
|
|
|
+ if (box && callout && layoutElement) {
|
|
|
+ const previousPositions =
|
|
|
+ previousLayoutInfoRef.current?.positions.find(
|
|
|
+ pos => pos.entryId === entryId
|
|
|
+ )?.positions
|
|
|
+ const hasScreenPos = Boolean(entry.screenPos)
|
|
|
+ entryViews.push({
|
|
|
+ entryId,
|
|
|
+ wrapper,
|
|
|
+ indicator,
|
|
|
+ box,
|
|
|
+ callout,
|
|
|
+ layout: layoutElement,
|
|
|
+ hasScreenPos,
|
|
|
+ height: 0,
|
|
|
+ entry,
|
|
|
+ previousPositions,
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ debugConsole.log(
|
|
|
+ 'Entry wrapper is missing indicator, box or callout, so ignoring',
|
|
|
+ wrapper
|
|
|
+ )
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- if (entryViews.length === 0) {
|
|
|
- resetLayout()
|
|
|
- return
|
|
|
- }
|
|
|
|
|
|
- entryViews.sort((a, b) => a.entry.offset - b.entry.offset)
|
|
|
-
|
|
|
- // Do the DOM interaction in three phases:
|
|
|
- //
|
|
|
- // - Apply the `display` property to all elements whose visibility has
|
|
|
- // changed. This needs to happen first in order to measure heights.
|
|
|
- // - Measure the height of each entry
|
|
|
- // - Move each entry without animation to their original position
|
|
|
- // relative to the editor content
|
|
|
- // - 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 display for each entry that needs it
|
|
|
- hideOrShowEntries(entryViews)
|
|
|
-
|
|
|
- // Next, measure the height of each entry
|
|
|
- for (const entryView of entryViews) {
|
|
|
- if (entryView.hasScreenPos) {
|
|
|
- entryView.height = entryView.layout.offsetHeight
|
|
|
+ if (entryViews.length === 0) {
|
|
|
+ resetLayout()
|
|
|
+ return
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // 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
|
|
|
- // previous layout. This will be the first entry in the list if there was
|
|
|
- // no previous layout.
|
|
|
- focusedEntryIndex = Math.min(
|
|
|
- previousLayoutInfoRef.current.focusedEntryIndex,
|
|
|
- entryViews.length - 1
|
|
|
- )
|
|
|
- // 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)
|
|
|
+ entryViews.sort((a, b) => a.entry.offset - b.entry.offset)
|
|
|
+
|
|
|
+ // Do the DOM interaction in three phases:
|
|
|
+ //
|
|
|
+ // - Apply the `display` property to all elements whose visibility has
|
|
|
+ // changed. This needs to happen first in order to measure heights.
|
|
|
+ // - Measure the height of each entry
|
|
|
+ // - Move each entry without animation to their original position
|
|
|
+ // relative to the editor content
|
|
|
+ // - 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 display for each entry that needs it
|
|
|
+ hideOrShowEntries(entryViews)
|
|
|
+
|
|
|
+ // Next, measure the height of each entry
|
|
|
+ for (const entryView of entryViews) {
|
|
|
+ if (entryView.hasScreenPos) {
|
|
|
+ entryView.height = entryView.layout.offsetHeight
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // If there is no entry with a screen position, bail out
|
|
|
- if (focusedEntryIndex === -1) {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const focusedEntryView = entryViews[focusedEntryIndex]
|
|
|
-
|
|
|
- // 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.hasScreenPos
|
|
|
- ? entryViews.slice(focusedEntryIndex + 1)
|
|
|
- : [...entryViews]
|
|
|
- const entryViewsBefore = focusedEntryView.hasScreenPos
|
|
|
- ? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
|
|
|
- : []
|
|
|
-
|
|
|
- debugConsole.log('focusedEntryIndex', focusedEntryIndex)
|
|
|
-
|
|
|
- let lastEntryBottom = 0
|
|
|
- let firstEntryTop = 0
|
|
|
-
|
|
|
- // 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)
|
|
|
- updateEntryPositions(focusedEntryView, entryTop, lineHeight)
|
|
|
- lastEntryBottom = entryTop + focusedEntryView.height
|
|
|
- firstEntryTop = entryTop
|
|
|
- }
|
|
|
+ // 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
|
|
|
+ // previous layout. This will be the first entry in the list if there was
|
|
|
+ // no previous layout.
|
|
|
+ focusedEntryIndex = Math.min(
|
|
|
+ previousLayoutInfoRef.current.focusedEntryIndex,
|
|
|
+ entryViews.length - 1
|
|
|
+ )
|
|
|
+ // 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Calculate positions for entries that are below the focused entry
|
|
|
- calculateEntryViewPositions(
|
|
|
- entryViewsAfter,
|
|
|
- lineHeight,
|
|
|
- (originalTop: number, height: number) => {
|
|
|
- const top = Math.max(originalTop, lastEntryBottom + padding)
|
|
|
- lastEntryBottom = top + height
|
|
|
- return top
|
|
|
+ // If there is no entry with a screen position, bail out
|
|
|
+ if (focusedEntryIndex === -1) {
|
|
|
+ return
|
|
|
}
|
|
|
- )
|
|
|
|
|
|
- // Calculate positions for entries that are above the focused entry
|
|
|
- calculateEntryViewPositions(
|
|
|
- entryViewsBefore,
|
|
|
- lineHeight,
|
|
|
- (originalTop: number, height: number) => {
|
|
|
- const originalBottom = originalTop + height
|
|
|
- const bottom = Math.min(originalBottom, firstEntryTop - padding)
|
|
|
- const top = bottom - height
|
|
|
- firstEntryTop = top
|
|
|
- return top
|
|
|
+ const focusedEntryView = entryViews[focusedEntryIndex]
|
|
|
+
|
|
|
+ // 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.hasScreenPos
|
|
|
+ ? entryViews.slice(focusedEntryIndex + 1)
|
|
|
+ : [...entryViews]
|
|
|
+ const entryViewsBefore = focusedEntryView.hasScreenPos
|
|
|
+ ? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
|
|
|
+ : []
|
|
|
+
|
|
|
+ debugConsole.log('focusedEntryIndex', focusedEntryIndex)
|
|
|
+
|
|
|
+ let lastEntryBottom = 0
|
|
|
+ let firstEntryTop = 0
|
|
|
+
|
|
|
+ // 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)
|
|
|
+ updateEntryPositions(focusedEntryView, entryTop, lineHeight)
|
|
|
+ lastEntryBottom = entryTop + focusedEntryView.height
|
|
|
+ firstEntryTop = entryTop
|
|
|
}
|
|
|
- )
|
|
|
|
|
|
- // Calculate the new top overflow
|
|
|
- const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
|
|
|
+ // Calculate positions for entries that are below the focused entry
|
|
|
+ calculateEntryViewPositions(
|
|
|
+ entryViewsAfter,
|
|
|
+ lineHeight,
|
|
|
+ (originalTop: number, height: number) => {
|
|
|
+ const top = Math.max(originalTop, lastEntryBottom + padding)
|
|
|
+ lastEntryBottom = top + height
|
|
|
+ return top
|
|
|
+ }
|
|
|
+ )
|
|
|
|
|
|
- // Check whether the positions of any entry have changed since the last
|
|
|
- // layout
|
|
|
- const positions = entryViews.map(
|
|
|
- (entryView): EntryPositions => ({
|
|
|
- entryId: entryView.entryId,
|
|
|
- positions: entryView.positions,
|
|
|
- })
|
|
|
- )
|
|
|
+ // Calculate positions for entries that are above the focused entry
|
|
|
+ calculateEntryViewPositions(
|
|
|
+ entryViewsBefore,
|
|
|
+ lineHeight,
|
|
|
+ (originalTop: number, height: number) => {
|
|
|
+ const originalBottom = originalTop + height
|
|
|
+ const bottom = Math.min(originalBottom, firstEntryTop - padding)
|
|
|
+ const top = bottom - height
|
|
|
+ firstEntryTop = top
|
|
|
+ return top
|
|
|
+ }
|
|
|
+ )
|
|
|
|
|
|
- const positionsChanged = !positionsEqual(
|
|
|
- previousLayoutInfoRef.current.positions,
|
|
|
- positions
|
|
|
- )
|
|
|
+ // Calculate the new top overflow
|
|
|
+ const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
|
|
|
|
|
|
- // Check whether the top overflow or review panel height have changed
|
|
|
- const overflowTopChanged =
|
|
|
- overflowTop !== previousLayoutInfoRef.current.overflowTop
|
|
|
+ // Check whether the positions of any entry have changed since the last
|
|
|
+ // layout
|
|
|
+ const positions = entryViews.map(
|
|
|
+ (entryView): EntryPositions => ({
|
|
|
+ entryId: entryView.entryId,
|
|
|
+ positions: entryView.positions,
|
|
|
+ })
|
|
|
+ )
|
|
|
|
|
|
- const height = lastEntryBottom + navPaddedHeight
|
|
|
- const heightChanged = height !== previousLayoutInfoRef.current.height
|
|
|
- const isMoveRequired = positionsChanged || overflowTopChanged
|
|
|
+ const positionsChanged = !positionsEqual(
|
|
|
+ previousLayoutInfoRef.current.positions,
|
|
|
+ positions
|
|
|
+ )
|
|
|
|
|
|
- // 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)
|
|
|
- }
|
|
|
+ // Check whether the top overflow or review panel height have changed
|
|
|
+ const overflowTopChanged =
|
|
|
+ overflowTop !== previousLayoutInfoRef.current.overflowTop
|
|
|
|
|
|
- // Inform the editor of the new top overflow and/or height if either has
|
|
|
- // changed
|
|
|
- if (overflowTopChanged || heightChanged) {
|
|
|
- window.dispatchEvent(
|
|
|
- new CustomEvent('review-panel:event', {
|
|
|
- detail: {
|
|
|
- type: 'sizes',
|
|
|
- payload: {
|
|
|
- overflowTop,
|
|
|
- height,
|
|
|
- },
|
|
|
- },
|
|
|
- })
|
|
|
- )
|
|
|
- }
|
|
|
+ const height = lastEntryBottom + navPaddedHeight
|
|
|
+ const heightChanged = height !== previousLayoutInfoRef.current.height
|
|
|
+ const isMoveRequired = positionsChanged || overflowTopChanged
|
|
|
|
|
|
- // Do the final move
|
|
|
- if (isMoveRequired) {
|
|
|
- if (animate) {
|
|
|
- container.classList.remove('no-animate')
|
|
|
- moveEntriesToFinalPositions(entryViews, overflowTop, false)
|
|
|
- } else {
|
|
|
+ // Move entries into their initial positions, if animating, avoiding
|
|
|
+ // animation until the final animated move
|
|
|
+ if (animate && isMoveRequired) {
|
|
|
container.classList.add('no-animate')
|
|
|
- moveEntriesToFinalPositions(entryViews, overflowTop, true)
|
|
|
+ moveEntriesToInitialPosition(entryViews, overflowTop)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Inform the editor of the new top overflow and/or height if either has
|
|
|
+ // changed
|
|
|
+ if (overflowTopChanged || heightChanged) {
|
|
|
+ window.dispatchEvent(
|
|
|
+ new CustomEvent('review-panel:event', {
|
|
|
+ detail: {
|
|
|
+ type: 'sizes',
|
|
|
+ payload: {
|
|
|
+ overflowTop,
|
|
|
+ height,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ })
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
- // Force reflow now to ensure that entries are moved without animation
|
|
|
- // eslint-disable-next-line no-void
|
|
|
- void container.offsetHeight
|
|
|
+ // Do the final move
|
|
|
+ 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')
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- container.classList.remove('no-animate')
|
|
|
+ previousLayoutInfoRef.current = {
|
|
|
+ positions,
|
|
|
+ focusedEntryIndex,
|
|
|
+ height,
|
|
|
+ overflowTop,
|
|
|
}
|
|
|
- }
|
|
|
+ },
|
|
|
+ [entries, lineHeight, navHeight, reviewPanelOpen, toolbarHeight]
|
|
|
+ )
|
|
|
|
|
|
- previousLayoutInfoRef.current = {
|
|
|
- positions,
|
|
|
- focusedEntryIndex,
|
|
|
- height,
|
|
|
- overflowTop,
|
|
|
- }
|
|
|
- }
|
|
|
+ useLayoutEffect(() => {
|
|
|
+ const callback = (event: Event) => {
|
|
|
+ const e = event as CustomEvent
|
|
|
|
|
|
- useEventListener('review-panel:layout', (event: CustomEvent) => {
|
|
|
- if (!layoutSuspended) {
|
|
|
- // Clear previous positions if forcing a layout
|
|
|
- if (event.detail.force) {
|
|
|
- previousLayoutInfoRef.current = initialLayoutInfo
|
|
|
+ if (!layoutSuspended) {
|
|
|
+ // Clear previous positions if forcing a layout
|
|
|
+ if (e.detail.force) {
|
|
|
+ previousLayoutInfoRef.current = initialLayoutInfo
|
|
|
+ }
|
|
|
+ layout(e.detail.animate)
|
|
|
}
|
|
|
- layout(event.detail.animate)
|
|
|
}
|
|
|
- })
|
|
|
+
|
|
|
+ window.addEventListener('review-panel:layout', callback)
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('review-panel:layout', callback)
|
|
|
+ }
|
|
|
+ }, [layoutSuspended, layout])
|
|
|
|
|
|
// Layout on first render. This is necessary to ensure layout happens when
|
|
|
// switching from overview to current file view
|