Просмотр исходного кода

Merge pull request #19796 from overleaf/dp-more-comments-2

Add More Comments buttons

GitOrigin-RevId: 3cd6539683cbc53ceb63488ab9e9b0ffe53e079c
David 2 лет назад
Родитель
Сommit
a331ac6116

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -881,6 +881,7 @@
   "month": "",
   "more": "",
   "more_actions": "",
+  "more_comments": "",
   "more_info": "",
   "more_options": "",
   "more_options_for_border_settings_coming_soon": "",

+ 80 - 54
services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx

@@ -29,6 +29,8 @@ import ReviewPanelEmptyState from './review-panel-empty-state'
 import useEventListener from '@/shared/hooks/use-event-listener'
 import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
 import { addCommentStateField } from '@/features/source-editor/extensions/add-comment'
+import ReviewPanelMoreCommentsButton from './review-panel-more-comments-button'
+import useMoreCommments from '../hooks/use-more-comments'
 
 type AggregatedRanges = {
   changes: Change<EditOperation>[]
@@ -47,59 +49,6 @@ const ReviewPanelCurrentFile: FC = () => {
   const containerRef = useRef<HTMLDivElement | null>(null)
   const previousFocusedItem = useRef(new Map<string, number>())
 
-  const updatePositions = useCallback(() => {
-    const docId = ranges?.docId
-
-    if (containerRef.current && docId) {
-      const positioningRes = positionItems(
-        containerRef.current,
-        previousFocusedItem.current.get(docId) || 0,
-        docId
-      )
-
-      if (positioningRes) {
-        previousFocusedItem.current.set(
-          positioningRes.docId,
-          positioningRes.activeItemIndex
-        )
-      }
-    }
-  }, [ranges?.docId])
-
-  useEffect(() => {
-    const timer = window.setTimeout(() => {
-      updatePositions()
-    }, 50)
-
-    return () => {
-      window.clearTimeout(timer)
-    }
-  }, [state, updatePositions])
-
-  const handleContainer = useCallback(
-    (element: HTMLDivElement | null) => {
-      containerRef.current = element
-      if (containerRef.current) {
-        containerRef.current.addEventListener(
-          'review-panel:position',
-          updatePositions
-        )
-      }
-    },
-    [updatePositions]
-  )
-
-  useEffect(() => {
-    return () => {
-      if (containerRef.current) {
-        containerRef.current.removeEventListener(
-          'review-panel:position',
-          updatePositions
-        )
-      }
-    }
-  }, [updatePositions])
-
   const buildAggregatedRanges = useCallback(() => {
     if (ranges) {
       const output: AggregatedRanges = {
@@ -127,7 +76,7 @@ const ReviewPanelCurrentFile: FC = () => {
 
       if (threads) {
         for (const comment of ranges.comments) {
-          if (!threads[comment.op.t]?.resolved) {
+          if (threads[comment.op.t] && !threads[comment.op.t]?.resolved) {
             output.comments.push(comment)
           }
         }
@@ -246,6 +195,71 @@ const ReviewPanelCurrentFile: FC = () => {
     return entries
   }, [addCommentRanges, positions])
 
+  const {
+    onEntriesPositioned,
+    onMoreCommentsAboveClick,
+    onMoreCommentsBelowClick,
+  } = useMoreCommments(
+    aggregatedRanges?.changes ?? [],
+    aggregatedRanges?.comments ?? [],
+    addCommentRanges
+  )
+
+  const updatePositions = useCallback(() => {
+    const docId = ranges?.docId
+
+    if (containerRef.current && docId) {
+      const positioningRes = positionItems(
+        containerRef.current,
+        previousFocusedItem.current.get(docId) || 0,
+        docId
+      )
+
+      onEntriesPositioned()
+
+      if (positioningRes) {
+        previousFocusedItem.current.set(
+          positioningRes.docId,
+          positioningRes.activeItemIndex
+        )
+      }
+    }
+  }, [ranges?.docId, onEntriesPositioned])
+
+  useEffect(() => {
+    const timer = window.setTimeout(() => {
+      updatePositions()
+    }, 50)
+
+    return () => {
+      window.clearTimeout(timer)
+    }
+  }, [state, updatePositions])
+
+  const handleContainer = useCallback(
+    (element: HTMLDivElement | null) => {
+      containerRef.current = element
+      if (containerRef.current) {
+        containerRef.current.addEventListener(
+          'review-panel:position',
+          updatePositions
+        )
+      }
+    },
+    [updatePositions]
+  )
+
+  useEffect(() => {
+    return () => {
+      if (containerRef.current) {
+        containerRef.current.removeEventListener(
+          'review-panel:position',
+          updatePositions
+        )
+      }
+    }
+  }, [updatePositions])
+
   if (!aggregatedRanges) {
     return null
   }
@@ -253,6 +267,12 @@ const ReviewPanelCurrentFile: FC = () => {
   return (
     <>
       {showEmptyState && <ReviewPanelEmptyState />}
+      {onMoreCommentsAboveClick && (
+        <ReviewPanelMoreCommentsButton
+          onClick={onMoreCommentsAboveClick}
+          direction="upward"
+        />
+      )}
 
       <div ref={handleContainer}>
         {addCommentEntries.map(entry => {
@@ -294,6 +314,12 @@ const ReviewPanelCurrentFile: FC = () => {
             )
         )}
       </div>
+      {onMoreCommentsBelowClick && (
+        <ReviewPanelMoreCommentsButton
+          onClick={onMoreCommentsBelowClick}
+          direction="downward"
+        />
+      )}
     </>
   )
 }

+ 32 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-more-comments-button.tsx

@@ -0,0 +1,32 @@
+import { FC, memo } from 'react'
+import { Button } from 'react-bootstrap'
+import MaterialIcon from '@/shared/components/material-icon'
+import classNames from 'classnames'
+import { useTranslation } from 'react-i18next'
+
+const MoreCommentsButton: FC<{
+  onClick: () => void
+  direction: 'upward' | 'downward'
+}> = ({ onClick, direction }) => {
+  const { t } = useTranslation()
+
+  return (
+    <div
+      className={classNames('review-panel-more-comments-button-container', {
+        downwards: direction === 'downward',
+        upwards: direction === 'upward',
+      })}
+    >
+      <Button
+        bsSize="small"
+        className="btn-secondary review-panel-more-comments-button"
+        onClick={onClick}
+      >
+        <MaterialIcon type={`arrow_${direction}_alt`} />
+        {t('more_comments')}
+      </Button>
+    </div>
+  )
+}
+
+export default memo(MoreCommentsButton)

+ 148 - 0
services/web/frontend/js/features/review-panel-new/hooks/use-more-comments.ts

@@ -0,0 +1,148 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
+import {
+  Change,
+  CommentOperation,
+  EditOperation,
+} from '../../../../../types/change'
+import { DecorationSet, EditorView } from '@codemirror/view'
+import { EditorSelection } from '@codemirror/state'
+import _ from 'lodash'
+
+const useMoreCommments = (
+  changes: Change<EditOperation>[],
+  comments: Change<CommentOperation>[],
+  newComments: DecorationSet
+): {
+  onEntriesPositioned: () => void
+  onMoreCommentsAboveClick: null | (() => void)
+  onMoreCommentsBelowClick: null | (() => void)
+} => {
+  const view = useCodeMirrorViewContext()
+
+  const [positionAbove, setPositionAbove] = useState<number | null>(null)
+  const [positionBelow, setPositionBelow] = useState<number | null>(null)
+
+  const updateEntryPositions = useMemo(
+    () =>
+      _.debounce(
+        () =>
+          view.requestMeasure({
+            key: 'review-panel-more-comments',
+            read(view) {
+              const container = view.scrollDOM
+
+              if (!container) {
+                return { positionAbove: null, positionBelow: null }
+              }
+
+              const containerTop = container.scrollTop
+              const containerBottom = containerTop + container.clientHeight
+
+              // First check for any entries in view by looking for the actual rendered entries
+              for (const entryElt of container.querySelectorAll<HTMLElement>(
+                '.review-panel-entry'
+              )) {
+                const entryTop = entryElt?.offsetTop ?? 0
+                const entryBottom = entryTop + (entryElt?.offsetHeight ?? 0)
+
+                if (entryBottom > containerTop && entryTop < containerBottom) {
+                  // Some part of the entry is in view
+                  return { positionAbove: null, positionBelow: null }
+                }
+              }
+
+              // Find the max and min positions in the visible part of the viewport
+              const visibleFrom = view.lineBlockAtHeight(containerTop).from
+              const visibleTo = view.lineBlockAtHeight(containerBottom).to
+
+              // Then go through the positions to find the first entry above and below the visible viewport.
+              // We can't use the rendered entries for this because only the entries that are in the viewport (or
+              // have been in the viewport during the current page view session) are actually rendered.
+              let firstEntryAbove: number | null = null
+              let firstEntryBelow: number | null = null
+
+              const updateFirstEntryAboveBelowPositions = (
+                position: number
+              ) => {
+                if (visibleFrom === null || position < visibleFrom) {
+                  firstEntryAbove = Math.max(firstEntryAbove ?? 0, position)
+                }
+
+                if (visibleTo === null || position > visibleTo) {
+                  firstEntryBelow = Math.min(
+                    firstEntryBelow ?? Number.MAX_VALUE,
+                    position
+                  )
+                }
+              }
+
+              for (const entry of [...changes, ...comments]) {
+                updateFirstEntryAboveBelowPositions(entry.op.p)
+              }
+
+              const cursor = newComments.iter()
+              while (cursor.value) {
+                updateFirstEntryAboveBelowPositions(cursor.from)
+                cursor.next()
+              }
+
+              return {
+                positionAbove: firstEntryAbove,
+                positionBelow: firstEntryBelow,
+              }
+            },
+            write({ positionAbove, positionBelow }) {
+              setPositionAbove(positionAbove)
+              setPositionBelow(positionBelow)
+            },
+          }),
+        200
+      ),
+    [changes, comments, newComments, view]
+  )
+
+  useEffect(() => {
+    const scrollerElt = document.getElementsByClassName('cm-scroller')[0]
+    if (scrollerElt) {
+      scrollerElt.addEventListener('scroll', updateEntryPositions)
+      return () => {
+        scrollerElt.removeEventListener('scroll', updateEntryPositions)
+      }
+    }
+  }, [updateEntryPositions])
+
+  const onMoreCommentsClick = useCallback(
+    (position: number) => {
+      view.dispatch({
+        effects: EditorView.scrollIntoView(position, {
+          y: 'center',
+        }),
+        selection: EditorSelection.cursor(position),
+      })
+    },
+    [view]
+  )
+
+  const onMoreCommentsAboveClick = useCallback(() => {
+    if (positionAbove !== null) {
+      onMoreCommentsClick(positionAbove)
+    }
+  }, [positionAbove, onMoreCommentsClick])
+
+  const onMoreCommentsBelowClick = useCallback(() => {
+    if (positionBelow !== null) {
+      onMoreCommentsClick(positionBelow)
+    }
+  }, [positionBelow, onMoreCommentsClick])
+
+  return {
+    onEntriesPositioned: updateEntryPositions,
+    onMoreCommentsAboveClick:
+      positionAbove !== null ? onMoreCommentsAboveClick : null,
+    onMoreCommentsBelowClick:
+      positionBelow !== null ? onMoreCommentsBelowClick : null,
+  }
+}
+
+export default useMoreCommments

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

@@ -1,4 +1,6 @@
 .review-panel-new {
+  @review-panel-header-height: 69px;
+
   &.review-panel-container {
     height: 100%;
     flex-shrink: 0;
@@ -125,6 +127,7 @@
     position: fixed;
     top: var(--review-panel-top);
     width: var(--review-panel-width);
+    height: @review-panel-header-height;
     z-index: 2;
     display: flex;
     flex-direction: column;
@@ -396,7 +399,7 @@
   .review-panel-overview {
     padding: 4px;
     position: absolute;
-    top: 69px;
+    top: @review-panel-header-height;
     bottom: 59px;
     width: 100%;
     overflow: auto;
@@ -501,6 +504,31 @@
     }
   }
 
+  .review-panel-more-comments-button-container {
+    position: fixed;
+    width: var(--review-panel-width);
+    display: flex;
+    justify-content: center;
+    z-index: 3;
+
+    &.downwards {
+      // TODO: fix this to not use a magic number when we have updated the footer ui
+      top: calc(100% - 102px);
+    }
+
+    &.upwards {
+      top: calc(var(--review-panel-top) + @review-panel-header-height + 16px);
+    }
+  }
+
+  .review-panel-more-comments-button {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: 2px 8px;
+    height: 24px;
+  }
+
   &.review-panel-subview-overview {
     &.review-panel-container {
       overflow-y: hidden;

+ 1 - 0
services/web/locales/en.json

@@ -1243,6 +1243,7 @@
   "monthly": "Monthly",
   "more": "More",
   "more_actions": "More actions",
+  "more_comments": "More comments",
   "more_info": "More Info",
   "more_lowercase": "more",
   "more_options": "More options",