positioned-entries.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. import { useEffect, useRef } from 'react'
  2. import { useLayoutContext } from '../../../../shared/context/layout-context'
  3. import {
  4. ReviewPanelEntry,
  5. ReviewPanelEntryScreenPos,
  6. } from '../../../../../../types/review-panel/entry'
  7. import { debugConsole } from '../../../../utils/debugging'
  8. import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
  9. import useEventListener from '../../../../shared/hooks/use-event-listener'
  10. import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
  11. import { dispatchReviewPanelLayout } from '../../extensions/changes/change-manager'
  12. import { isEqual } from 'lodash'
  13. type Positions = {
  14. entryTop: number
  15. callout: { top: number; height: number; inverted: boolean }
  16. inViewport: boolean
  17. }
  18. type EntryView = {
  19. entryId: keyof ReviewPanelDocEntries
  20. wrapper: HTMLElement
  21. indicator: HTMLElement | null
  22. box: HTMLElement
  23. callout: HTMLElement
  24. layout: HTMLElement
  25. height: number
  26. entry: ReviewPanelEntry
  27. hasScreenPos: boolean
  28. positions?: Positions
  29. previousPositions?: Positions
  30. }
  31. type EntryPositions = Pick<EntryView, 'entryId' | 'positions'>
  32. type PositionedEntriesProps = {
  33. entries: Array<[keyof ReviewPanelDocEntries, ReviewPanelEntry]>
  34. contentHeight: number
  35. children: React.ReactNode
  36. }
  37. const initialLayoutInfo = {
  38. focusedEntryIndex: 0,
  39. overflowTop: 0,
  40. height: 0,
  41. positions: [] as EntryPositions[],
  42. }
  43. function css(el: HTMLElement, props: React.CSSProperties) {
  44. Object.assign(el.style, props)
  45. }
  46. function calculateCalloutPosition(
  47. screenPos: ReviewPanelEntryScreenPos,
  48. entryTop: number,
  49. lineHeight: number
  50. ) {
  51. const height = screenPos.height ?? lineHeight
  52. const originalTop = screenPos.y
  53. const inverted = entryTop <= originalTop
  54. return {
  55. top: inverted ? entryTop + height : originalTop + height - 1,
  56. height: Math.abs(entryTop - originalTop),
  57. inverted,
  58. }
  59. }
  60. function positionsEqual(
  61. entryPos1: EntryPositions[],
  62. entryPos2: EntryPositions[]
  63. ) {
  64. return isEqual(entryPos1, entryPos2)
  65. }
  66. function updateEntryPositions(
  67. entryView: EntryView,
  68. entryTop: number,
  69. lineHeight: number
  70. ) {
  71. const callout = calculateCalloutPosition(
  72. entryView.entry.screenPos,
  73. entryTop,
  74. lineHeight
  75. )
  76. entryView.positions = {
  77. entryTop,
  78. callout,
  79. inViewport: entryView.entry.inViewport,
  80. }
  81. }
  82. function calculateEntryViewPositions(
  83. entryViews: EntryView[],
  84. lineHeight: number,
  85. calculateTop: (originalTop: number, height: number) => number
  86. ) {
  87. for (const entryView of entryViews) {
  88. if (entryView.hasScreenPos) {
  89. const entryTop = calculateTop(
  90. entryView.entry.screenPos.y,
  91. entryView.height
  92. )
  93. updateEntryPositions(entryView, entryTop, lineHeight)
  94. }
  95. }
  96. }
  97. function hideOrShowEntries(entryViews: EntryView[]) {
  98. for (const entryView of entryViews) {
  99. // Completely hide any entry that has no screen position
  100. entryView.wrapper.classList.toggle(
  101. 'rp-entry-hidden',
  102. !entryView.hasScreenPos
  103. )
  104. }
  105. }
  106. function applyEntryTop(entryView: EntryView, top: number) {
  107. entryView.box.style.top = top + 'px'
  108. if (entryView.indicator) {
  109. entryView.indicator.style.top = top + 'px'
  110. }
  111. }
  112. function applyEntryVisibility(entryView: EntryView) {
  113. // The entry element is invisible by default, to avoid flickering when
  114. // positioning for the first time. Here we make sure it becomes visible after
  115. // acquiring a screen position.
  116. if (entryView.entry.inViewport) {
  117. entryView.box.style.visibility = 'visible'
  118. entryView.callout.style.visibility = 'visible'
  119. }
  120. }
  121. // Position everything where it was before, taking into account the new top
  122. // overflow
  123. function moveEntriesToInitialPosition(
  124. entryViews: EntryView[],
  125. overflowTop: number
  126. ) {
  127. for (const entryView of entryViews) {
  128. const { callout: calloutEl, positions } = entryView
  129. if (positions) {
  130. const { entryTop, callout } = positions
  131. // Position the main wrapper in its original position, if it had
  132. // one, or its new position otherwise
  133. const entryTopInitial = entryView.previousPositions
  134. ? entryView.previousPositions.entryTop
  135. : entryTop
  136. applyEntryVisibility(entryView)
  137. applyEntryTop(entryView, entryTopInitial + overflowTop)
  138. // Position the callout element in its original position, if it had
  139. // one, or its new position otherwise
  140. calloutEl.classList.toggle('rp-entry-callout-inverted', callout.inverted)
  141. const calloutTopInitial = entryView.previousPositions
  142. ? entryView.previousPositions.callout.top
  143. : callout.top
  144. css(calloutEl, {
  145. top: calloutTopInitial + overflowTop + 'px',
  146. height: callout.height + 'px',
  147. })
  148. }
  149. }
  150. }
  151. function moveEntriesToFinalPositions(
  152. entryViews: EntryView[],
  153. overflowTop: number,
  154. shouldApplyVisibility: boolean
  155. ) {
  156. for (const entryView of entryViews) {
  157. const { callout: calloutEl, positions } = entryView
  158. if (positions) {
  159. const { entryTop, callout } = positions
  160. if (shouldApplyVisibility) {
  161. applyEntryVisibility(entryView)
  162. }
  163. // Position the main wrapper, if it's moved
  164. if (entryView.previousPositions?.entryTop !== entryTop) {
  165. entryView.box.style.top = entryTop + overflowTop + 'px'
  166. }
  167. if (entryView.indicator) {
  168. entryView.indicator.style.top = entryTop + overflowTop + 'px'
  169. }
  170. // Position the callout element
  171. if (entryView.previousPositions?.callout.top !== callout.top) {
  172. calloutEl.style.top = callout.top + overflowTop + 'px'
  173. }
  174. }
  175. }
  176. }
  177. function PositionedEntries({
  178. entries,
  179. contentHeight,
  180. children,
  181. }: PositionedEntriesProps) {
  182. const { navHeight, toolbarHeight, lineHeight, layoutSuspended } =
  183. useReviewPanelValueContext()
  184. const containerRef = useRef<HTMLDivElement | null>(null)
  185. const { reviewPanelOpen } = useLayoutContext()
  186. const previousLayoutInfoRef = useRef(initialLayoutInfo)
  187. const resetLayout = () => {
  188. previousLayoutInfoRef.current = initialLayoutInfo
  189. }
  190. const layout = (animate = true) => {
  191. const container = containerRef.current
  192. if (!container) {
  193. return
  194. }
  195. const padding = reviewPanelOpen ? 8 : 4
  196. const toolbarPaddedHeight = reviewPanelOpen ? toolbarHeight + 6 : 0
  197. const navPaddedHeight = reviewPanelOpen ? navHeight + 4 : 0
  198. // Create a list of entry views, typing together DOM elements and model.
  199. // No measuring or style change is done at this point.
  200. const entryViews: EntryView[] = []
  201. // TODO: Look into tying the entry to the DOM element without going via a DOM data attribute
  202. for (const wrapper of container.querySelectorAll<HTMLElement>(
  203. '.rp-entry-wrapper'
  204. )) {
  205. const entryId = wrapper.dataset.entryId as
  206. | EntryView['entryId']
  207. | undefined
  208. if (!entryId) {
  209. throw new Error('Could not find an entry ID')
  210. }
  211. const entry = entries.find(value => value[0] === entryId)?.[1]
  212. if (!entry) {
  213. throw new Error(`Could not find an entry for ID ${entryId}`)
  214. }
  215. const indicator = wrapper.querySelector<HTMLElement>(
  216. '.rp-entry-indicator'
  217. )
  218. const box = wrapper.querySelector<HTMLElement>('.rp-entry')
  219. const callout = wrapper.querySelector<HTMLElement>('.rp-entry-callout')
  220. const layoutElement = reviewPanelOpen ? box : indicator
  221. if (box && callout && layoutElement) {
  222. const previousPositions = previousLayoutInfoRef.current?.positions.find(
  223. pos => pos.entryId === entryId
  224. )?.positions
  225. const hasScreenPos = Boolean(entry.screenPos)
  226. entryViews.push({
  227. entryId,
  228. wrapper,
  229. indicator,
  230. box,
  231. callout,
  232. layout: layoutElement,
  233. hasScreenPos,
  234. height: 0,
  235. entry,
  236. previousPositions,
  237. })
  238. } else {
  239. debugConsole.log(
  240. 'Entry wrapper is missing indicator, box or callout, so ignoring',
  241. wrapper
  242. )
  243. }
  244. }
  245. if (entryViews.length === 0) {
  246. resetLayout()
  247. return
  248. }
  249. entryViews.sort((a, b) => a.entry.offset - b.entry.offset)
  250. // Do the DOM interaction in three phases:
  251. //
  252. // - Apply the `display` property to all elements whose visibility has
  253. // changed. This needs to happen first in order to measure heights.
  254. // - Measure the height of each entry
  255. // - Move each entry without animation to their original position
  256. // relative to the editor content
  257. // - Re-enable animation and position each entry
  258. //
  259. // The idea is to batch DOM reads and writes to avoid layout thrashing. In
  260. // this case, the best we can do is a write phase, a read phase then a
  261. // final write phase.
  262. // See https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/
  263. // First, update display for each entry that needs it
  264. hideOrShowEntries(entryViews)
  265. // Next, measure the height of each entry
  266. for (const entryView of entryViews) {
  267. if (entryView.hasScreenPos) {
  268. entryView.height = entryView.layout.offsetHeight
  269. }
  270. }
  271. // Calculate positions for all positioned entries, starting by calculating
  272. // which entry to put in its desired position and anchor everything else
  273. // around. If there is an explicitly focused entry, use that.
  274. let focusedEntryIndex = entryViews.findIndex(view => view.entry.focused)
  275. if (focusedEntryIndex === -1) {
  276. // There is no explicitly focused entry, so use the focused entry from the
  277. // previous layout. This will be the first entry in the list if there was
  278. // no previous layout.
  279. focusedEntryIndex = Math.min(
  280. previousLayoutInfoRef.current.focusedEntryIndex,
  281. entryViews.length - 1
  282. )
  283. // If the entry from the previous layout has no screen position, fall back
  284. // to the first entry in the list that does.
  285. if (!entryViews[focusedEntryIndex].hasScreenPos) {
  286. focusedEntryIndex = entryViews.findIndex(view => view.hasScreenPos)
  287. }
  288. }
  289. // If there is no entry with a screen position, bail out
  290. if (focusedEntryIndex === -1) {
  291. return
  292. }
  293. const focusedEntryView = entryViews[focusedEntryIndex]
  294. if (!focusedEntryView.hasScreenPos) {
  295. return
  296. }
  297. // If the focused entry has no screenPos, we can't position other
  298. // entryViews relative to it, so we position all other entryViews as
  299. // though the focused entry is at the top and the rest follow it
  300. const entryViewsAfter = focusedEntryView.hasScreenPos
  301. ? entryViews.slice(focusedEntryIndex + 1)
  302. : [...entryViews]
  303. const entryViewsBefore = focusedEntryView.hasScreenPos
  304. ? entryViews.slice(0, focusedEntryIndex).reverse() // Work through backwards, starting with the one just above
  305. : []
  306. debugConsole.log('focusedEntryIndex', focusedEntryIndex)
  307. let lastEntryBottom = 0
  308. let firstEntryTop = 0
  309. // Put the focused entry as close as possible to where it wants to be
  310. if (focusedEntryView.hasScreenPos) {
  311. const focusedEntryScreenPos = focusedEntryView.entry.screenPos
  312. const entryTop = Math.max(focusedEntryScreenPos.y, toolbarPaddedHeight)
  313. updateEntryPositions(focusedEntryView, entryTop, lineHeight)
  314. lastEntryBottom = entryTop + focusedEntryView.height
  315. firstEntryTop = entryTop
  316. }
  317. // Calculate positions for entries that are below the focused entry
  318. calculateEntryViewPositions(
  319. entryViewsAfter,
  320. lineHeight,
  321. (originalTop: number, height: number) => {
  322. const top = Math.max(originalTop, lastEntryBottom + padding)
  323. lastEntryBottom = top + height
  324. return top
  325. }
  326. )
  327. // Calculate positions for entries that are above the focused entry
  328. calculateEntryViewPositions(
  329. entryViewsBefore,
  330. lineHeight,
  331. (originalTop: number, height: number) => {
  332. const originalBottom = originalTop + height
  333. const bottom = Math.min(originalBottom, firstEntryTop - padding)
  334. const top = bottom - height
  335. firstEntryTop = top
  336. return top
  337. }
  338. )
  339. // Calculate the new top overflow
  340. const overflowTop = Math.max(0, toolbarPaddedHeight - firstEntryTop)
  341. // Check whether the positions of any entry have changed since the last
  342. // layout
  343. const positions = entryViews.map(
  344. (entryView): EntryPositions => ({
  345. entryId: entryView.entryId,
  346. positions: entryView.positions,
  347. })
  348. )
  349. const positionsChanged = !positionsEqual(
  350. previousLayoutInfoRef.current.positions,
  351. positions
  352. )
  353. // Check whether the top overflow or review panel height have changed
  354. const overflowTopChanged =
  355. overflowTop !== previousLayoutInfoRef.current.overflowTop
  356. const height = lastEntryBottom + navPaddedHeight
  357. const heightChanged = height !== previousLayoutInfoRef.current.height
  358. const isMoveRequired = positionsChanged || overflowTopChanged
  359. // Move entries into their initial positions, if animating, avoiding
  360. // animation until the final animated move
  361. if (animate && isMoveRequired) {
  362. container.classList.add('no-animate')
  363. moveEntriesToInitialPosition(entryViews, overflowTop)
  364. }
  365. // Inform the editor of the new top overflow and/or height if either has
  366. // changed
  367. if (overflowTopChanged || heightChanged) {
  368. window.dispatchEvent(
  369. new CustomEvent('review-panel:event', {
  370. detail: {
  371. type: 'sizes',
  372. payload: {
  373. overflowTop,
  374. height,
  375. },
  376. },
  377. })
  378. )
  379. }
  380. // Do the final move
  381. if (isMoveRequired) {
  382. if (animate) {
  383. container.classList.remove('no-animate')
  384. moveEntriesToFinalPositions(entryViews, overflowTop, false)
  385. } else {
  386. container.classList.add('no-animate')
  387. moveEntriesToFinalPositions(entryViews, overflowTop, true)
  388. // Force reflow now to ensure that entries are moved without animation
  389. // eslint-disable-next-line no-void
  390. void container.offsetHeight
  391. container.classList.remove('no-animate')
  392. }
  393. }
  394. previousLayoutInfoRef.current = {
  395. positions,
  396. focusedEntryIndex,
  397. height,
  398. overflowTop,
  399. }
  400. }
  401. useEventListener('review-panel:layout', (event: CustomEvent) => {
  402. if (!layoutSuspended) {
  403. // Clear previous positions if forcing a layout
  404. if (event.detail.force) {
  405. previousLayoutInfoRef.current = initialLayoutInfo
  406. }
  407. layout(event.detail.animate)
  408. }
  409. })
  410. // Layout on first render. This is necessary to ensure layout happens when
  411. // switching from overview to current file view
  412. useEffect(() => {
  413. dispatchReviewPanelLayout()
  414. }, [])
  415. // Ensure a full layout is performed after opening or closing the review panel
  416. useEffect(() => {
  417. previousLayoutInfoRef.current = initialLayoutInfo
  418. }, [reviewPanelOpen])
  419. return (
  420. <div
  421. ref={containerRef}
  422. className="rp-entry-list-react"
  423. style={{ height: `${contentHeight}px` }}
  424. >
  425. {children}
  426. </div>
  427. )
  428. }
  429. export default PositionedEntries