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

Merge pull request #21240 from overleaf/ii-bs5-review-panel-old

[web] BS5 review panel old

GitOrigin-RevId: da018b8f2946afb21ab63da0003453e20781f04c
ilkin-overleaf 1 год назад
Родитель
Сommit
5c3d9117c5
25 измененных файлов с 1583 добавлено и 419 удалено
  1. 20 3
      services/web/frontend/js/features/source-editor/components/review-panel/editor-widgets/editor-widgets.tsx
  2. 22 6
      services/web/frontend/js/features/source-editor/components/review-panel/entries/add-comment-entry.tsx
  3. 22 6
      services/web/frontend/js/features/source-editor/components/review-panel/entries/aggregate-change-entry.tsx
  4. 12 2
      services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions-entry.tsx
  5. 22 19
      services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/modal.tsx
  6. 25 5
      services/web/frontend/js/features/source-editor/components/review-panel/entries/change-entry.tsx
  7. 19 7
      services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx
  8. 1 1
      services/web/frontend/js/features/source-editor/components/review-panel/entries/resolved-comment-entry.tsx
  9. 10 2
      services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx
  10. 2 4
      services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx
  11. 6 1
      services/web/frontend/js/features/source-editor/components/review-panel/overview-file.tsx
  12. 235 222
      services/web/frontend/js/features/source-editor/components/review-panel/positioned-entries.tsx
  13. 9 0
      services/web/frontend/js/features/source-editor/components/review-panel/toggler.tsx
  14. 11 7
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx
  15. 1 1
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx
  16. 10 3
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toggle-menu.tsx
  17. 7 7
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/track-changes-menu.tsx
  18. 44 32
      services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx
  19. 1 4
      services/web/frontend/js/shared/components/loading-spinner.tsx
  20. 15 83
      services/web/frontend/stylesheets/app/editor/review-panel.less
  21. 30 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss
  22. 2 0
      services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss
  23. 2 2
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss
  24. 1047 2
      services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel.scss
  25. 8 0
      services/web/frontend/stylesheets/core/utilities.less

+ 20 - 3
services/web/frontend/js/features/source-editor/components/review-panel/editor-widgets/editor-widgets.tsx

@@ -16,6 +16,8 @@ import useScopeEventListener from '@/shared/hooks/use-scope-event-listener'
 import { memo, useCallback } from 'react'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import classnames from 'classnames'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 function EditorWidgets() {
   const { t } = useTranslation()
@@ -71,10 +73,20 @@ function EditorWidgets() {
           {nChanges > 1 && permissions.write && (
             <>
               <BulkActions.Button onClick={handleShowBulkAcceptDialog}>
-                <Icon type="check" /> {t('accept_all')} ({nChanges})
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="check" />}
+                  bs5={<MaterialIcon type="check" />}
+                />
+                &nbsp;
+                {t('accept_all')} ({nChanges})
               </BulkActions.Button>
               <BulkActions.Button onClick={handleShowBulkRejectDialog}>
-                <Icon type="times" /> {t('reject_all')} ({nChanges})
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="times" />}
+                  bs5={<MaterialIcon type="close" />}
+                />
+                &nbsp;
+                {t('reject_all')} ({nChanges})
               </BulkActions.Button>
             </>
           )}
@@ -83,7 +95,12 @@ function EditorWidgets() {
             !isRestrictedTokenMember &&
             currentDocEntries?.['add-comment'] && (
               <AddCommentButton onClick={handleAddNewCommentClick}>
-                <Icon type="comment" /> {t('add_comment')}
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="comment" />}
+                  bs5={<MaterialIcon type="mode_comment" />}
+                />
+                &nbsp;
+                {t('add_comment')}
               </AddCommentButton>
             )}
         </div>

+ 22 - 6
services/web/frontend/js/features/source-editor/components/review-panel/entries/add-comment-entry.tsx

@@ -11,6 +11,9 @@ import {
   useReviewPanelValueContext,
 } from '../../../context/review-panel/review-panel-context'
 import classnames from 'classnames'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 function AddCommentEntry() {
   const { t } = useTranslation()
@@ -116,9 +119,7 @@ function AddCommentEntry() {
           <>
             <div className="rp-new-comment">
               {isSubmitting ? (
-                <div className="rp-loading">
-                  <Icon type="spinner" spin />
-                </div>
+                <LoadingSpinner className="d-flex justify-content-center" />
               ) : (
                 <AutoExpandingTextArea
                   className="rp-comment-input"
@@ -137,19 +138,34 @@ function AddCommentEntry() {
                 className="rp-entry-button-cancel"
                 onClick={handleCancelNewComment}
               >
-                <Icon type="times" /> {t('cancel')}
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="times" />}
+                  bs5={<MaterialIcon type="close" />}
+                />
+                &nbsp;
+                {t('cancel')}
               </EntryActions.Button>
               <EntryActions.Button
                 onClick={handleSubmitNewComment}
                 disabled={isSubmitting || !content.length}
               >
-                <Icon type="comment" /> {t('comment')}
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="comment" />}
+                  bs5={<MaterialIcon type="mode_comment" />}
+                />
+                &nbsp;
+                {t('comment')}
               </EntryActions.Button>
             </EntryActions>
           </>
         ) : (
           <AddCommentButton onClick={handleStartNewComment}>
-            <Icon type="comment" /> {t('add_comment')}
+            <BootstrapVersionSwitcher
+              bs3={<Icon type="comment" />}
+              bs5={<MaterialIcon type="mode_comment" />}
+            />
+            &nbsp;
+            {t('add_comment')}
           </AddCommentButton>
         )}
       </div>

+ 22 - 6
services/web/frontend/js/features/source-editor/components/review-panel/entries/aggregate-change-entry.tsx

@@ -12,6 +12,8 @@ import { BaseChangeEntryProps } from '../types/base-change-entry-props'
 import useIndicatorHover from '../hooks/use-indicator-hover'
 import EntryIndicator from './entry-indicator'
 import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 interface AggregateChangeEntryProps extends BaseChangeEntryProps {
   replacedContent: string
@@ -80,7 +82,10 @@ function AggregateChangeEntry({
         onMouseEnter={handleIndicatorMouseEnter}
         onClick={handleIndicatorClick}
       >
-        <Icon type="pencil" />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="pencil" />}
+          bs5={<MaterialIcon type="edit" />}
+        />
       </EntryIndicator>
       <div
         className={classnames('rp-entry', 'rp-entry-aggregate', {
@@ -89,7 +94,10 @@ function AggregateChangeEntry({
       >
         <div className="rp-entry-body">
           <div className="rp-entry-action-icon">
-            <Icon type="pencil" />
+            <BootstrapVersionSwitcher
+              bs3={<Icon type="pencil" />}
+              bs5={<MaterialIcon type="edit" />}
+            />
           </div>
           <div className="rp-entry-details">
             <div className="rp-entry-description">
@@ -97,7 +105,7 @@ function AggregateChangeEntry({
               <del className="rp-content-highlight">{deletionContent}</del>
               {deletionNeedsCollapsing && (
                 <button
-                  className="rp-collapse-toggle btn-inline-link"
+                  className="rp-collapse-toggle"
                   onClick={handleDeletionToggleCollapse}
                 >
                   {isDeletionCollapsed
@@ -109,7 +117,7 @@ function AggregateChangeEntry({
               <ins className="rp-content-highlight">{insertionContent}</ins>
               {insertionNeedsCollapsing && (
                 <button
-                  className="rp-collapse-toggle btn-inline-link"
+                  className="rp-collapse-toggle"
                   onClick={handleInsertionToggleCollapse}
                 >
                   {isInsertionCollapsed
@@ -139,10 +147,18 @@ function AggregateChangeEntry({
         {permissions.write && (
           <EntryActions>
             <EntryActions.Button onClick={() => rejectChanges(entryIds)}>
-              <Icon type="times" /> {t('reject')}
+              <BootstrapVersionSwitcher
+                bs3={<Icon type="times" />}
+                bs5={<MaterialIcon type="close" />}
+              />
+              &nbsp;{t('reject')}
             </EntryActions.Button>
             <EntryActions.Button onClick={() => acceptChanges(entryIds)}>
-              <Icon type="check" /> {t('accept')}
+              <BootstrapVersionSwitcher
+                bs3={<Icon type="check" />}
+                bs5={<MaterialIcon type="check" />}
+              />
+              &nbsp;{t('accept')}
             </EntryActions.Button>
           </EntryActions>
         )}

+ 12 - 2
services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/bulk-actions-entry.tsx

@@ -5,6 +5,8 @@ import Icon from '../../../../../../shared/components/icon'
 import BulkActions from './bulk-actions'
 import Modal, { useBulkActionsModal } from './modal'
 import { ReviewPanelBulkActionsEntry } from '../../../../../../../../types/review-panel/entry'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type BulkActionsEntryProps = {
   entryId: ReviewPanelBulkActionsEntry['type']
@@ -30,10 +32,18 @@ function BulkActionsEntry({ entryId, nChanges }: BulkActionsEntryProps) {
             <EntryCallout className="rp-entry-callout-bulk-actions" />
             <BulkActions className="rp-entry">
               <BulkActions.Button onClick={handleShowBulkRejectDialog}>
-                <Icon type="times" /> {t('reject_all')} ({nChanges})
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="times" />}
+                  bs5={<MaterialIcon type="close" />}
+                />
+                &nbsp;{t('reject_all')} ({nChanges})
               </BulkActions.Button>
               <BulkActions.Button onClick={handleShowBulkAcceptDialog}>
-                <Icon type="check" /> {t('accept_all')} ({nChanges})
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="check" />}
+                  bs5={<MaterialIcon type="check" />}
+                />
+                &nbsp;{t('accept_all')} ({nChanges})
               </BulkActions.Button>
             </BulkActions>
           </>

+ 22 - 19
services/web/frontend/js/features/source-editor/components/review-panel/entries/bulk-actions-entry/modal.tsx

@@ -1,8 +1,13 @@
 import { useState, useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Button, Modal as BootstrapModal } from 'react-bootstrap'
-import AccessibleModal from '../../../../../../shared/components/accessible-modal'
 import { useReviewPanelUpdaterFnsContext } from '../../../../context/review-panel/review-panel-context'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 type BulkActionsModalProps = {
   show: boolean
@@ -22,30 +27,28 @@ function Modal({
   const { t } = useTranslation()
 
   return (
-    <AccessibleModal show={show} onHide={() => setShow(false)}>
-      <BootstrapModal.Header closeButton>
-        <h3>{isAccept ? t('accept_all') : t('reject_all')}</h3>
-      </BootstrapModal.Header>
-      <BootstrapModal.Body>
+    <OLModal show={show} onHide={() => setShow(false)}>
+      <OLModalHeader closeButton>
+        <OLModalTitle>
+          {isAccept ? t('accept_all') : t('reject_all')}
+        </OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody>
         <p>
           {isAccept
             ? t('bulk_accept_confirm', { nChanges })
             : t('bulk_reject_confirm', { nChanges })}
         </p>
-      </BootstrapModal.Body>
-      <BootstrapModal.Footer>
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          onClick={() => setShow(false)}
-        >
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={() => setShow(false)}>
           {t('cancel')}
-        </Button>
-        <Button bsStyle={null} className="btn-primary" onClick={onConfirm}>
+        </OLButton>
+        <OLButton variant="primary" onClick={onConfirm}>
           {t('ok')}
-        </Button>
-      </BootstrapModal.Footer>
-    </AccessibleModal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 

+ 25 - 5
services/web/frontend/js/features/source-editor/components/review-panel/entries/change-entry.tsx

@@ -13,6 +13,8 @@ import comparePropsWithShallowArrayCompare from '../utils/compare-props-with-sha
 import useIndicatorHover from '../hooks/use-indicator-hover'
 import EntryIndicator from './entry-indicator'
 import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 interface ChangeEntryProps extends BaseChangeEntryProps {
   type: ReviewPanelChangeEntry['type']
@@ -71,7 +73,14 @@ function ChangeEntry({
         onMouseEnter={handleIndicatorMouseEnter}
         onClick={handleIndicatorClick}
       >
-        {isInsert ? <Icon type="pencil" /> : <i className="rp-icon-delete" />}
+        {isInsert ? (
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="pencil" />}
+            bs5={<MaterialIcon type="edit" />}
+          />
+        ) : (
+          <i className="rp-icon-delete" />
+        )}
       </EntryIndicator>
       <div
         className={classnames('rp-entry', `rp-entry-${type}`, {
@@ -81,7 +90,10 @@ function ChangeEntry({
         <div className="rp-entry-body">
           <div className="rp-entry-action-icon">
             {isInsert ? (
-              <Icon type="pencil" />
+              <BootstrapVersionSwitcher
+                bs3={<Icon type="pencil" />}
+                bs5={<MaterialIcon type="edit" />}
+              />
             ) : (
               <i className="rp-icon-delete" />
             )}
@@ -106,7 +118,7 @@ function ChangeEntry({
                 )}
                 {needsCollapsing && (
                   <button
-                    className="rp-collapse-toggle btn-inline-link"
+                    className="rp-collapse-toggle"
                     onClick={handleToggleCollapse}
                   >
                     {isCollapsed
@@ -137,10 +149,18 @@ function ChangeEntry({
         {permissions.write && (
           <EntryActions>
             <EntryActions.Button onClick={() => rejectChanges(entryIds)}>
-              <Icon type="times" /> {t('reject')}
+              <BootstrapVersionSwitcher
+                bs3={<Icon type="times" />}
+                bs5={<MaterialIcon type="close" />}
+              />
+              &nbsp;{t('reject')}
             </EntryActions.Button>
             <EntryActions.Button onClick={() => acceptChanges(entryIds)}>
-              <Icon type="check" /> {t('accept')}
+              <BootstrapVersionSwitcher
+                bs3={<Icon type="check" />}
+                bs5={<MaterialIcon type="check" />}
+              />
+              &nbsp;{t('accept')}
             </EntryActions.Button>
           </EntryActions>
         )}

+ 19 - 7
services/web/frontend/js/features/source-editor/components/review-panel/entries/comment-entry.tsx

@@ -16,6 +16,9 @@ import { ReviewPanelCommentEntry } from '../../../../../../../types/review-panel
 import useIndicatorHover from '../hooks/use-indicator-hover'
 import EntryIndicator from './entry-indicator'
 import { useEntryClick } from '@/features/source-editor/components/review-panel/hooks/use-entry-click'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 type CommentEntryProps = {
   docId: DocId
@@ -120,7 +123,10 @@ function CommentEntry({
           onMouseEnter={handleIndicatorMouseEnter}
           onClick={handleIndicatorClick}
         >
-          <Icon type="comment" />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="comment" />}
+            bs5={<MaterialIcon type="mode_comment" />}
+          />
         </EntryIndicator>
         <div
           className={classnames('rp-entry', 'rp-entry-comment', {
@@ -130,7 +136,7 @@ function CommentEntry({
           ref={entryDivRef}
         >
           {!submitting && (!thread || thread.messages.length === 0) && (
-            <div className="rp-loading">{t('no_comments')}</div>
+            <div className="text-center p-1">{t('no_comments')}</div>
           )}
           <div className="rp-comment-loaded">
             {thread.messages.map(comment => (
@@ -143,9 +149,7 @@ function CommentEntry({
             ))}
           </div>
           {submitting && (
-            <div className="rp-loading">
-              <Icon type="spinner" spin />
-            </div>
+            <LoadingSpinner className="d-flex justify-content-center" />
           )}
           {permissions.comment && (
             <div className="rp-comment-reply">
@@ -163,7 +167,11 @@ function CommentEntry({
           <EntryActions>
             {permissions.comment && permissions.write && (
               <EntryActions.Button onClick={handleAnimateAndCallOnResolve}>
-                <Icon type="inbox" /> {t('resolve')}
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="inbox" />}
+                  bs5={<MaterialIcon type="inbox" />}
+                />
+                &nbsp;{t('resolve')}
               </EntryActions.Button>
             )}
             {permissions.comment && (
@@ -171,7 +179,11 @@ function CommentEntry({
                 onClick={handleOnReply}
                 disabled={!replyContent.length}
               >
-                <Icon type="reply" /> {t('reply')}
+                <BootstrapVersionSwitcher
+                  bs3={<Icon type="reply" />}
+                  bs5={<MaterialIcon type="reply" />}
+                />
+                &nbsp;{t('reply')}
               </EntryActions.Button>
             )}
           </EntryActions>

+ 1 - 1
services/web/frontend/js/features/source-editor/components/review-panel/entries/resolved-comment-entry.tsx

@@ -61,7 +61,7 @@ function ResolvedCommentEntry({
             <>
               &nbsp;
               <button
-                className="rp-collapse-toggle btn-inline-link"
+                className="rp-collapse-toggle"
                 onClick={() => setIsCollapsed(value => !value)}
               >
                 {isCollapsed ? `… (${t('show_all')})` : ` (${t('show_less')})`}

+ 10 - 2
services/web/frontend/js/features/source-editor/components/review-panel/nav.tsx

@@ -8,6 +8,8 @@ import {
 import { isCurrentFileView, isOverviewView } from '../../utils/sub-view'
 import { useCallback } from 'react'
 import { useResizeObserver } from '../../../../shared/hooks/use-resize-observer'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 function Nav() {
   const { t } = useTranslation()
@@ -40,7 +42,10 @@ function Nav() {
         })}
         onClick={() => handleSetSubview('cur_file')}
       >
-        <Icon type="file-text-o" />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="file-text-o" />}
+          bs5={<MaterialIcon type="description" className="align-middle" />}
+        />
         <span className="rp-nav-label">{t('current_file')}</span>
       </button>
       <button
@@ -55,7 +60,10 @@ function Nav() {
         })}
         onClick={() => handleSetSubview('overview')}
       >
-        <Icon type="list" />
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="list" />}
+          bs5={<MaterialIcon type="list" className="align-middle" />}
+        />
         <span className="rp-nav-label">{t('overview')}</span>
       </button>
     </div>

+ 2 - 4
services/web/frontend/js/features/source-editor/components/review-panel/overview-container.tsx

@@ -2,11 +2,11 @@ import Container from './container'
 import Toggler from './toggler'
 import Toolbar from './toolbar/toolbar'
 import Nav from './nav'
-import Icon from '../../../../shared/components/icon'
 import OverviewFile from './overview-file'
 import { useReviewPanelValueContext } from '../../context/review-panel/review-panel-context'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
 import { memo } from 'react'
+import LoadingSpinner from '@/shared/components/loading-spinner'
 
 function OverviewContainer() {
   const { isOverviewLoading } = useReviewPanelValueContext()
@@ -24,9 +24,7 @@ function OverviewContainer() {
         aria-labelledby="review-panel-tab-overview"
       >
         {isOverviewLoading ? (
-          <div className="rp-loading">
-            <Icon type="spinner" spin />
-          </div>
+          <LoadingSpinner className="d-flex justify-content-center my-2" />
         ) : (
           docs?.map(doc => (
             <OverviewFile

+ 6 - 1
services/web/frontend/js/features/source-editor/components/review-panel/overview-file.tsx

@@ -8,6 +8,8 @@ import classnames from 'classnames'
 import { ReviewPanelDocEntries } from '../../../../../../types/review-panel/review-panel'
 import { MainDocument } from '../../../../../../types/project-settings'
 import OverviewFileEntries from '@/features/source-editor/components/review-panel/entries/overview-file-entries'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type OverviewFileProps = {
   docId: MainDocument['doc']['id']
@@ -45,7 +47,10 @@ function OverviewFile({ docId, docPath }: OverviewFileProps) {
               'rp-overview-file-header-collapse-on': docCollapsed,
             })}
           >
-            <Icon type="angle-down" />
+            <BootstrapVersionSwitcher
+              bs3={<Icon type="angle-down" />}
+              bs5={<MaterialIcon type="expand_more" />}
+            />
           </span>
           {docPath}
           {docCollapsed && (

+ 235 - 222
services/web/frontend/js/features/source-editor/components/review-panel/positioned-entries.tsx

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

+ 9 - 0
services/web/frontend/js/features/source-editor/components/review-panel/toggler.tsx

@@ -1,5 +1,8 @@
 import { useTranslation } from 'react-i18next'
 import { useReviewPanelUpdaterFnsContext } from '../../context/review-panel/review-panel-context'
+import Icon from '@/shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 function Toggler() {
   const { t } = useTranslation()
@@ -18,6 +21,12 @@ function Toggler() {
       onClick={handleTogglerClick}
     >
       <span className="sr-only">{t('hotkey_toggle_review_panel')}</span>
+      <span className="review-panel-toggler-icon">
+        <BootstrapVersionSwitcher
+          bs3={<Icon type="chevron-left" />}
+          bs5={<MaterialIcon type="chevron_left" />}
+        />
+      </span>
     </button>
   )
 }

+ 11 - 7
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-dropdown.tsx

@@ -1,7 +1,6 @@
 import { useTranslation } from 'react-i18next'
 import { useState, useMemo, useCallback } from 'react'
 import Icon from '../../../../../shared/components/icon'
-import Tooltip from '../../../../../shared/components/tooltip'
 import ResolvedCommentsScroller from './resolved-comments-scroller'
 import classnames from 'classnames'
 import {
@@ -16,6 +15,10 @@ import { ReviewPanelResolvedCommentThread } from '../../../../../../../types/rev
 import { DocId } from '../../../../../../../types/project-settings'
 import { ReviewPanelEntry } from '../../../../../../../types/review-panel/entry'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import LoadingSpinner from '@/shared/components/loading-spinner'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 export interface FilteredResolvedComments
   extends ReviewPanelResolvedCommentThread {
@@ -97,7 +100,7 @@ function ResolvedCommentsDropdown() {
         onClick={() => setIsOpen(false)}
       />
 
-      <Tooltip
+      <OLTooltip
         id="resolved-comments-toggle"
         description={t('resolved_comments')}
         overlayProps={{ container: document.body, placement: 'bottom' }}
@@ -107,9 +110,12 @@ function ResolvedCommentsDropdown() {
           onClick={handleResolvedCommentsClick}
           aria-label={t('resolved_comments')}
         >
-          <Icon type="inbox" />
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="inbox" />}
+            bs5={<MaterialIcon type="inbox" />}
+          />
         </button>
-      </Tooltip>
+      </OLTooltip>
 
       <div
         className={classnames('resolved-comments-dropdown', {
@@ -117,9 +123,7 @@ function ResolvedCommentsDropdown() {
         })}
       >
         {isLoading ? (
-          <div className="rp-loading">
-            <Icon type="spinner" spin />
-          </div>
+          <LoadingSpinner className="d-flex justify-content-center my-2" />
         ) : isOpen ? (
           <ResolvedCommentsScroller
             resolvedComments={filteredResolvedComments}

+ 1 - 1
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/resolved-comments-scroller.tsx

@@ -31,7 +31,7 @@ function ResolvedCommentsScroller({
         />
       ))}
       {!resolvedComments.length && (
-        <div className="rp-loading">{t('no_resolved_threads')}</div>
+        <div className="text-center p-1">{t('no_resolved_threads')}</div>
       )}
     </div>
   )

+ 10 - 3
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toggle-menu.tsx

@@ -10,6 +10,8 @@ import {
 } from '../../../context/review-panel/review-panel-context'
 import { send, sendMB } from '../../../../../infrastructure/event-tracking'
 import classnames from 'classnames'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 const sendAnalytics = () => {
   send('subscription-funnel', 'editor-click-feature', 'real-time-track-changes')
@@ -39,7 +41,7 @@ function ToggleMenu() {
       <span className="review-panel-toolbar-label">
         {wantTrackChanges && (
           <span className="review-panel-toolbar-icon-on">
-            <Icon type="circle" />
+            <span className="track-changes-indicator-circle" />
           </span>
         )}
 
@@ -47,13 +49,18 @@ function ToggleMenu() {
           className="review-panel-toolbar-collapse-button"
           onClick={handleToggleFullTCStateCollapse}
         >
-          {wantTrackChanges ? <TrackChangesOn /> : <TrackChangesOff />}
+          <span>
+            {wantTrackChanges ? <TrackChangesOn /> : <TrackChangesOff />}
+          </span>
           <span
             className={classnames('rp-tc-state-collapse', {
               'rp-tc-state-collapse-on': shouldCollapse,
             })}
           >
-            <Icon type="angle-down" />
+            <BootstrapVersionSwitcher
+              bs3={<Icon type="angle-down" />}
+              bs5={<MaterialIcon type="expand_more" />}
+            />
           </span>
         </button>
       </span>

+ 7 - 7
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/track-changes-menu.tsx

@@ -1,5 +1,4 @@
 import { useTranslation } from 'react-i18next'
-import Tooltip from '@/shared/components/tooltip'
 import TrackChangesToggle from '@/features/source-editor/components/review-panel/toolbar/track-changes-toggle'
 import {
   useReviewPanelUpdaterFnsContext,
@@ -7,6 +6,7 @@ import {
 } from '@/features/source-editor/context/review-panel/review-panel-context'
 import { useProjectContext } from '@/shared/context/project-context'
 import classnames from 'classnames'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 
 function TrackChangesMenu() {
   const { t } = useTranslation()
@@ -28,7 +28,7 @@ function TrackChangesMenu() {
   return (
     <ul className="rp-tc-state" data-testid="review-panel-track-changes-menu">
       <li className="rp-tc-state-item rp-tc-state-item-everyone">
-        <Tooltip
+        <OLTooltip
           description={t('tc_switch_everyone_tip')}
           id="track-changes-switch-everyone"
           overlayProps={{
@@ -38,7 +38,7 @@ function TrackChangesMenu() {
           }}
         >
           <span className="rp-tc-state-item-name">{t('tc_everyone')}</span>
-        </Tooltip>
+        </OLTooltip>
 
         <TrackChangesToggle
           id="track-changes-everyone"
@@ -52,7 +52,7 @@ function TrackChangesMenu() {
       </li>
       {Object.values(formattedProjectMembers).map(member => (
         <li className="rp-tc-state-item" key={member.id}>
-          <Tooltip
+          <OLTooltip
             description={t('tc_switch_user_tip')}
             id="track-changes-switch-user"
             overlayProps={{
@@ -68,7 +68,7 @@ function TrackChangesMenu() {
             >
               {member.name}
             </span>
-          </Tooltip>
+          </OLTooltip>
 
           <TrackChangesToggle
             id={`track-changes-user-toggle-${member.id}`}
@@ -90,7 +90,7 @@ function TrackChangesMenu() {
       ))}
       <li className="rp-tc-state-separator" />
       <li className="rp-tc-state-item">
-        <Tooltip
+        <OLTooltip
           description={t('tc_switch_guests_tip')}
           id="track-changes-switch-guests"
           overlayProps={{
@@ -106,7 +106,7 @@ function TrackChangesMenu() {
           >
             {t('tc_guests')}
           </span>
-        </Tooltip>
+        </OLTooltip>
 
         <TrackChangesToggle
           id="track-changes-guests-toggle"

+ 44 - 32
services/web/frontend/js/features/source-editor/components/review-panel/upgrade-track-changes-modal.tsx

@@ -1,12 +1,21 @@
 import { useTranslation } from 'react-i18next'
-import { Row, Col, Button, Modal } from 'react-bootstrap'
-import AccessibleModal from '../../../../shared/components/accessible-modal'
 import Icon from '../../../../shared/components/icon'
 import { useProjectContext } from '../../../../shared/context/project-context'
 import { useUserContext } from '../../../../shared/context/user-context'
 import { startFreeTrial, upgradePlan } from '../../../../main/account-upgrade'
 import { memo } from 'react'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLRow from '@/features/ui/components/ol/ol-row'
+import OLCol from '@/features/ui/components/ol/ol-col'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 type UpgradeTrackChangesModalProps = {
   show: boolean
@@ -24,11 +33,11 @@ function UpgradeTrackChangesModal({
   const hasNewPaywallCta = useFeatureFlag('paywall-cta')
 
   return (
-    <AccessibleModal show={show} onHide={() => setShow(false)}>
-      <Modal.Header closeButton>
-        <h3>{t('upgrade_to_track_changes')}</h3>
-      </Modal.Header>
-      <Modal.Body>
+    <OLModal show={show} onHide={() => setShow(false)}>
+      <OLModalHeader closeButton>
+        <OLModalTitle>{t('upgrade_to_track_changes')}</OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody>
         <div className="teaser-video-container">
           {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
           <video className="teaser-video" autoPlay loop>
@@ -45,8 +54,8 @@ function UpgradeTrackChangesModal({
         <h4 className="teaser-title">
           {t('see_changes_in_your_documents_live')}
         </h4>
-        <Row>
-          <Col md={10} mdOffset={1}>
+        <OLRow>
+          <OLCol lg={{ span: 10, offset: 1 }}>
             <ul className="list-unstyled">
               {[
                 t('track_any_change_in_real_time'),
@@ -54,36 +63,43 @@ function UpgradeTrackChangesModal({
                 t('accept_or_reject_each_changes_individually'),
               ].map(translation => (
                 <li key={translation}>
-                  <Icon type="check" /> {translation}
+                  <BootstrapVersionSwitcher
+                    bs3={<Icon type="check" />}
+                    bs5={
+                      <MaterialIcon
+                        type="check"
+                        className="align-text-bottom"
+                      />
+                    }
+                  />
+                  &nbsp;{translation}
                 </li>
               ))}
             </ul>
-          </Col>
-        </Row>
+          </OLCol>
+        </OLRow>
         <p className="small">
           {t('already_subscribed_try_refreshing_the_page')}
         </p>
         {project.owner && (
-          <Row className="text-center">
+          <div className="text-center">
             {project.owner._id === user.id ? (
               user.allowedFreeTrial ? (
-                <Button
-                  bsStyle={null}
-                  className="btn-primary"
+                <OLButton
+                  variant="primary"
                   onClick={() => startFreeTrial('track-changes')}
                 >
                   {hasNewPaywallCta
                     ? t('get_track_changes')
                     : t('try_it_for_free')}
-                </Button>
+                </OLButton>
               ) : (
-                <Button
-                  bsStyle={null}
-                  className="btn-primary"
+                <OLButton
+                  variant="primary"
                   onClick={() => upgradePlan('project-sharing')}
                 >
                   {t('upgrade')}
-                </Button>
+                </OLButton>
               )
             ) : (
               <p>
@@ -94,19 +110,15 @@ function UpgradeTrackChangesModal({
                 </strong>
               </p>
             )}
-          </Row>
+          </div>
         )}
-      </Modal.Body>
-      <Modal.Footer>
-        <Button
-          bsStyle={null}
-          className="btn-secondary"
-          onClick={() => setShow(false)}
-        >
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={() => setShow(false)}>
           {t('close')}
-        </Button>
-      </Modal.Footer>
-    </AccessibleModal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }
 

+ 1 - 4
services/web/frontend/js/shared/components/loading-spinner.tsx

@@ -39,10 +39,7 @@ function LoadingSpinner({
   }
 
   const extraClasses = isBootstrap5()
-    ? [
-        'd-inline-flex',
-        align === 'left' ? 'align-items-start' : 'align-items-center',
-      ]
+    ? [align === 'left' ? 'align-items-start' : 'align-items-center']
     : null
 
   return (

+ 15 - 83
services/web/frontend/stylesheets/app/editor/review-panel.less

@@ -9,10 +9,8 @@
 @rp-border-grey: #d9d9d9;
 
 @rp-green: #2c8e30;
-@rp-green-on-dark: rgba(37, 107, 41, 0.5);
 @rp-red: #c5060b;
 @rp-yellow: #f3b111;
-@rp-yellow-on-dark: rgba(194, 93, 11, 0.5);
 @rp-grey: #aaaaaa;
 
 @rp-type-blue: #6b7797;
@@ -24,8 +22,6 @@
 @review-panel-width: 230px;
 @review-off-width: 22px;
 
-@rp-toolbar-height: 32px;
-
 @rp-entry-animation-speed: 0.3s;
 
 .rp-button() {
@@ -64,12 +60,6 @@
   }
 }
 
-.loading-panel {
-  .rp-size-expanded & {
-    right: @review-panel-width;
-  }
-}
-
 .review-panel-toolbar {
   display: none;
 
@@ -105,10 +95,6 @@
   margin-right: 5px;
 }
 
-.review-panel-toolbar-spinner {
-  margin-left: 5px;
-}
-
 .rp-tc-state {
   position: absolute;
   top: 100%;
@@ -173,10 +159,6 @@
   }
 }
 
-.rp-entry-list-inner {
-  position: relative;
-}
-
 .rp-entry-indicator {
   display: none;
   z-index: 2; // above .review-panel-toggler
@@ -752,11 +734,6 @@
   }
 }
 
-.rp-loading {
-  text-align: center;
-  padding: 5px;
-}
-
 .rp-nav {
   display: none;
   flex-shrink: 0;
@@ -806,18 +783,6 @@
   font-size: @rp-base-font-size;
 }
 
-#editor {
-  .rp-size-mini & {
-    right: @review-off-width;
-  }
-
-  .rp-size-expanded & {
-    right: @review-panel-width;
-    left: 0px;
-    width: auto;
-  }
-}
-
 .review-icon {
   display: inline-block;
   background: url('../../../../public/img/ol-icons/review-icon-dark-theme.svg')
@@ -919,6 +884,7 @@ button when (@is-overleaf-light = true) {
 }
 
 .rp-collapse-toggle {
+  .btn-inline-link;
   color: @rp-type-blue;
   font-weight: @rp-semibold-weight;
 
@@ -979,8 +945,8 @@ button when (@is-overleaf-light = true) {
   }
 
   .rp-size-expanded & {
-    &::after {
-      content: '\f105';
+    .review-panel-toggler-icon .fa {
+      transform: scale(0.7) rotate(180deg);
     }
   }
 
@@ -990,20 +956,16 @@ button when (@is-overleaf-light = true) {
     background-color: #fff;
   }
 
-  &::after {
-    content: '\f104';
-    font-family: FontAwesome;
-    line-height: 1;
-    -webkit-font-smoothing: antialiased;
-    -moz-osx-font-smoothing: grayscale;
-    font-size: 16px;
-    display: block;
+  .review-panel-toggler-icon {
     position: sticky;
     top: 50%;
     bottom: 50%;
     width: 100%;
-    text-align: center;
-    margin-top: -0.5em;
+
+    .fa {
+      font-size: 14px;
+      transform: scale(0.7);
+    }
   }
 }
 
@@ -1134,40 +1096,10 @@ button when (@is-overleaf-light = true) {
   }
 }
 
-.review-panel-indicator {
-  display: none;
-}
-
-.review-panel-mini {
-  width: 22px !important;
-  overflow: visible !important;
-
-  .rp-entry {
-    height: 12px;
-  }
-
-  .rp-entry-indicator {
-    position: absolute;
-    left: -6px;
-    right: 0;
-    display: flex;
-    width: 20px;
-    height: 20px;
-  }
-
-  .rp-entry-content {
-    display: none;
-    background: white;
-    border: 1px solid @rp-border-grey;
-    width: 200px;
-  }
-
-  .rp-entry:hover {
-    .rp-entry-content {
-      display: initial;
-      position: absolute;
-      left: -206px;
-      top: 0;
-    }
-  }
+.track-changes-indicator-circle {
+  display: inline-block;
+  width: 10px;
+  height: 10px;
+  border-radius: 100%;
+  background-color: @ol-green;
 }

+ 30 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/mixins.scss

@@ -143,3 +143,33 @@
   --link-hover-color: var(--link-hover-color-dark);
   --link-visited-color: var(--link-visited-color-dark);
 }
+
+@mixin triangle($direction, $width, $height, $color) {
+  position: absolute;
+  border-color: transparent;
+  border-style: solid;
+  width: 0;
+  height: 0;
+
+  @if $direction == top {
+    border-width: 0 calc(#{$width} / 2) #{$height} calc(#{$width} / 2);
+    border-bottom-color: $color;
+    border-left-color: transparent;
+    border-right-color: transparent;
+  } @else if $direction == bottom {
+    border-width: #{$height} calc(#{$width} / 2) 0 calc(#{$width} / 2);
+    border-top-color: $color;
+    border-left-color: transparent;
+    border-right-color: transparent;
+  } @else if $direction == right {
+    border-width: calc(#{$height} / 2) 0 calc(#{$height} / 2) #{$width};
+    border-left-color: $color;
+    border-top-color: transparent;
+    border-bottom-color: transparent;
+  } @else if $direction == left {
+    border-width: calc(#{$height} / 2) #{$width} calc(#{$height} / 2) 0;
+    border-right-color: $color;
+    border-top-color: transparent;
+    border-bottom-color: transparent;
+  }
+}

+ 2 - 0
services/web/frontend/stylesheets/bootstrap-5/components/loading-spinner.scss

@@ -1,4 +1,6 @@
 .loading {
+  display: inline-flex;
+
   .spinner-border-sm,
   .spinner-border {
     // Ensure the thickness of the spinner is independent of the font size of its container

+ 2 - 2
services/web/frontend/stylesheets/bootstrap-5/pages/editor/ide.scss

@@ -294,8 +294,8 @@ $editor-toggler-bg-dark-color: color.adjust(
 }
 
 .teaser-video-container {
-  margin: calc(var(--spacing-07) * -1) calc(var(--spacing-07) * -1)
-    var(--spacing-02) calc(var(--spacing-07) * -1);
+  margin: calc(var(--bs-modal-padding) * -1) calc(var(--bs-modal-padding) * -1)
+    var(--spacing-02) calc(var(--bs-modal-padding) * -1);
   overflow: hidden;
 }
 

+ 1047 - 2
services/web/frontend/stylesheets/bootstrap-5/pages/editor/review-panel.scss

@@ -1,11 +1,27 @@
 @use 'sass:color';
 
+$rp-type-blue: #6b7797;
+$rp-highlight-blue: #8a96b5;
+$rp-bg-blue: #dadfed;
+$rp-yellow: #f3b111;
+$rp-entry-arrow-width: 6px;
+
 :root {
   --review-icon: url('../../../../../public/img/ol-icons/review-icon-dark-theme.svg');
   --rp-base-font-size: var(--font-size-01);
+  --rp-small-font-size: 0.625rem; // 10px
+  --rp-icon-large-size: var(--font-size-04);
   --rp-bg-dim-blue: #fafafa;
   --rp-border-grey: #d9d9d9;
+  --rp-green: #2c8e30;
+  --rp-red: #c5060b;
   --rp-type-darkgrey: #3f3f3f;
+  --rp-entry-ribbon-width: 4px;
+  --rp-entry-arrow-width: 6px;
+  --rp-semibold-weight: 600;
+  --review-panel-width: 230px;
+  --review-off-width: 22px;
+  --rp-entry-animation-speed: 0.3s;
 }
 
 @include theme('light') {
@@ -21,7 +37,69 @@
   }
 }
 
-$rp-type-blue: #6b7797;
+%rp-button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background-color: $rp-highlight-blue;
+  color: var(--white);
+  text-align: center;
+  line-height: 1.3;
+  user-select: none;
+  border: 0;
+
+  &:hover,
+  &:focus {
+    outline: 0;
+    background-color: color.adjust($rp-highlight-blue, $lightness: -5%);
+    text-decoration: none;
+    color: var(--white);
+  }
+
+  &[disabled] {
+    background-color: color.mix($rp-highlight-blue, $white, 50%);
+
+    &:hover,
+    &:focus {
+      background-color: color.mix($rp-highlight-blue, $white, 50%);
+    }
+  }
+}
+
+.review-panel-toolbar {
+  display: none;
+
+  .rp-size-expanded & {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 0 var(--spacing-03);
+  }
+
+  position: relative;
+  border-bottom: 1px solid var(--rp-border-grey);
+  background-color: var(--rp-bg-dim-blue);
+  text-align: center;
+  z-index: 3;
+  flex-basis: 32px;
+  flex-shrink: 0;
+}
+
+.review-panel-toolbar-label {
+  cursor: pointer;
+  text-align: right;
+  flex-grow: 1;
+}
+
+.review-panel-toolbar-icon-on {
+  margin-right: var(--spacing-03);
+  color: var(--bg-accent-01);
+}
+
+.review-panel-toolbar-label-disabled {
+  margin-right: var(--spacing-03);
+  cursor: auto;
+}
 
 .rp-tc-state {
   position: absolute;
@@ -37,6 +115,17 @@ $rp-type-blue: #6b7797;
   text-align: left;
 }
 
+.rp-tc-state-collapse,
+.rp-overview-file-header-collapse {
+  display: inline-flex;
+  transition: transform 0.15s ease;
+  transform: rotateZ(0deg);
+
+  &-on {
+    transform: rotateZ(-90deg);
+  }
+}
+
 .rp-tc-state-item {
   display: flex;
   align-items: center;
@@ -47,6 +136,14 @@ $rp-type-blue: #6b7797;
   }
 }
 
+.rp-tc-state-separator {
+  border-bottom: 1px solid var(--rp-border-grey);
+}
+
+.rp-tc-state-item-everyone {
+  border-bottom: 1px solid var(--rp-border-grey);
+}
+
 .rp-tc-state-item-name {
   @extend .text-truncate;
 
@@ -58,6 +155,665 @@ $rp-type-blue: #6b7797;
   opacity: 0.35;
 }
 
+.rp-entry-list {
+  display: none;
+  width: 100%;
+
+  .rp-size-expanded &,
+  .rp-size-mini & {
+    display: block;
+  }
+
+  .rp-state-overview & {
+    flex-grow: 2;
+    overflow-y: auto;
+  }
+}
+
+.rp-entry-indicator {
+  display: none;
+  z-index: 2; // above .review-panel-toggler
+
+  .material-symbols {
+    font-size: var(--font-size-02);
+    vertical-align: sub;
+  }
+
+  .rp-size-mini & {
+    display: block;
+  }
+
+  .rp-floating-entry & {
+    display: block;
+    position: static;
+    width: calc(var(--review-off-width) - 4px);
+  }
+
+  .rp-size-mini &-add-comment {
+    display: none;
+  }
+
+  position: absolute;
+  left: 2px;
+  right: 2px;
+  text-align: center;
+  background-color: $rp-highlight-blue;
+  border-radius: var(--border-radius-base);
+  color: var(--white);
+  cursor: pointer;
+  transition:
+    top var(--rp-entry-animation-speed),
+    left 0.1s,
+    right 0.1s;
+
+  .no-animate & {
+    transition:
+      left 0.1s,
+      right 0.1s;
+  }
+
+  &-focused {
+    left: 0;
+    right: 4px;
+    z-index: 1;
+  }
+}
+
+.rp-entry-wrapper {
+  &:hover .rp-entry-insert,
+  &:hover .rp-entry-delete,
+  &:hover .rp-entry-aggregate,
+  &:hover .rp-entry-comment {
+    display: block;
+  }
+
+  &.rp-entry-hidden {
+    display: none;
+  }
+}
+
+.rp-entry {
+  .rp-state-current-file & {
+    position: absolute;
+    width: var(--review-panel-width);
+  }
+
+  .rp-state-current-file-mini &,
+  .rp-floating-entry & {
+    left: calc(var(--review-off-width) + var(--rp-entry-arrow-width));
+    box-shadow: 0 0 10px 5px rgba(0 0 0 / 20%);
+    z-index: 1;
+
+    &::before {
+      content: '';
+      position: absolute;
+      inset: calc((var(--review-off-width) + var(--rp-entry-arrow-width)) * -1)
+        calc((var(--review-off-width) + var(--rp-entry-arrow-width)) * -1)
+        calc((var(--review-off-width) + var(--rp-entry-arrow-width)) * -1)
+        calc(-2 * var(--rp-entry-arrow-width) - 2px);
+      z-index: -1;
+    }
+
+    &::after {
+      @include triangle(
+        left,
+        $rp-entry-arrow-width,
+        $rp-entry-arrow-width * 1.5,
+        inherit
+      );
+
+      top: calc(var(--review-off-width) / 2 - var(--rp-entry-arrow-width));
+      left: calc(
+        (var(--rp-entry-ribbon-width) + var(--rp-entry-arrow-width)) * -1
+      );
+      content: '';
+    }
+  }
+
+  .rp-state-current-file-mini & {
+    display: none;
+  }
+
+  .rp-floating-entry & {
+    position: absolute;
+    width: var(--review-panel-width);
+    left: calc(var(--review-off-width) + var(--rp-entry-arrow-width));
+    top: 0;
+  }
+
+  .rp-state-current-file-mini.rp-layout-left &,
+  .rp-floating-entry-layout-left & {
+    border-left-width: 0;
+    border-right-width: var(--rp-entry-ribbon-width);
+    border-right-style: solid;
+
+    &::before {
+      left: calc((var(--review-off-width) + var(--rp-entry-arrow-width)) * -1);
+    }
+
+    &::after {
+      @include triangle(
+        right,
+        $rp-entry-arrow-width,
+        $rp-entry-arrow-width * 1.5,
+        inherit
+      );
+
+      right: calc(
+        (var(--rp-entry-ribbon-width) + var(--rp-entry-arrow-width)) * -1
+      );
+      left: auto;
+    }
+  }
+
+  .rp-state-current-file-mini.rp-layout-left & {
+    left: auto;
+    right: calc(var(--review-off-width) + var(--rp-entry-arrow-width));
+
+    &::before {
+      right: calc(-2 * var(--rp-entry-arrow-width) - 2px);
+    }
+  }
+
+  .rp-floating-entry-layout-left & {
+    left: calc(
+      (var(--review-panel-width) + var(--rp-entry-arrow-width) + 2px) * -1
+    );
+
+    &::before {
+      right: calc(-2 * var(--rp-entry-arrow-width));
+    }
+  }
+
+  .rp-state-current-file-expanded & {
+    visibility: hidden;
+    left: 5px;
+    right: 5px;
+    width: auto;
+
+    &-focused {
+      left: -2px;
+      right: 12px;
+      z-index: 1;
+    }
+
+    &-add-comment {
+      right: auto;
+
+      &.rp-entry-adding-comment {
+        right: 5px;
+      }
+    }
+
+    &-bulk-actions {
+      right: auto;
+    }
+  }
+
+  .rp-state-overview & {
+    border-radius: 0;
+    border-bottom: solid 1px var(--rp-border-grey);
+    cursor: pointer;
+  }
+
+  .resolved-comments-dropdown & {
+    position: static;
+    margin-bottom: var(--spacing-03);
+  }
+
+  border-left: solid var(--rp-entry-ribbon-width) transparent;
+  border-radius: var(--border-radius-base);
+  background-color: var(--white);
+  transition:
+    top var(--rp-entry-animation-speed),
+    left 0.1s,
+    right 0.1s;
+
+  .no-animate & {
+    transition:
+      left 0.1s,
+      right 0.1s;
+  }
+
+  &-insert,
+  &-aggregate {
+    border-color: var(--rp-green);
+  }
+
+  &-delete {
+    border-color: var(--rp-red);
+  }
+
+  &-comment {
+    border-color: $rp-yellow;
+  }
+
+  &-comment-resolving {
+    top: 4px;
+    left: 6px;
+    opacity: 0;
+    z-index: 3;
+    transform: scale(0.1);
+    transform-origin: 0 0;
+    transition:
+      top 0.35s ease-out,
+      left 0.35s ease-out,
+      transform 0.35s ease-out,
+      opacity 0.35s ease-out 0.2s;
+  }
+
+  &-comment-resolved {
+    border-color: #aaa;
+    background-color: #efefef;
+  }
+
+  &-add-comment {
+    background-color: transparent;
+    right: auto;
+    border-left-width: 0;
+
+    &.rp-entry-adding-comment {
+      background-color: var(--white);
+      right: 5px;
+      border-left-width: 3px;
+      border-left-color: $rp-yellow;
+    }
+  }
+
+  &-bulk-actions {
+    background-color: transparent;
+    right: auto;
+    border-left-width: 0;
+  }
+}
+
+.rp-entry-body {
+  display: flex;
+  align-items: center;
+  padding: var(--spacing-02) var(--spacing-03);
+}
+
+.rp-entry-action-icon {
+  font-size: var(--rp-icon-large-size);
+  padding: 0 var(--spacing-02);
+  line-height: 0;
+
+  .rp-state-overview & {
+    display: none;
+  }
+}
+
+.rp-entry-details {
+  line-height: 1.4;
+  margin-left: var(--spacing-03);
+
+  // We need to set any low-enough flex base size (0px), making it growable (1) and non-shrinkable (0).
+  // This is needed to ensure that IE makes the element fill the available space.
+  flex: 1 0 1px;
+  overflow-x: auto;
+
+  .rp-state-overview & {
+    margin-left: 0;
+  }
+}
+
+.rp-entry-metadata {
+  font-size: var(--rp-small-font-size);
+}
+
+.rp-entry-metadata-element {
+  display: inline-block;
+}
+
+.rp-entry-user {
+  font-weight: var(--rp-semibold-weight);
+  font-style: normal;
+}
+
+.rp-comment-actions {
+  a {
+    color: $rp-type-blue;
+  }
+}
+
+.rp-content-highlight {
+  color: var(--rp-type-darkgrey);
+  font-weight: var(--rp-semibold-weight);
+  text-decoration: none;
+}
+
+del.rp-content-highlight {
+  text-decoration: line-through;
+}
+
+.rp-entry-actions {
+  display: flex;
+
+  .rp-state-overview .rp-entry-list & {
+    display: none;
+  }
+}
+
+.rp-entry-button {
+  @extend %rp-button;
+
+  flex: 1 1 50%;
+  border-right: solid 1px var(--white);
+  padding: var(--spacing-01) 0;
+
+  &:last-child {
+    border-bottom-right-radius: var(--border-radius-base);
+    border-right-width: 0;
+  }
+
+  .rp-state-current-file-mini.rp-layout-left &,
+  .rp-floating-entry-layout-left & {
+    &:first-child {
+      border-bottom-left-radius: var(--border-radius-base);
+    }
+
+    &:last-child {
+      border-bottom-right-radius: 0;
+    }
+  }
+
+  .material-symbols {
+    font-size: inherit;
+    font-weight: bold;
+  }
+}
+
+.rp-comment {
+  margin: var(--spacing-01) var(--spacing-03);
+  padding-bottom: var(--spacing-02);
+  line-height: 1.4;
+  border-bottom: solid 1px var(--rp-border-grey);
+
+  &:last-child {
+    margin-bottom: var(--spacing-01);
+    border-bottom-width: 0;
+  }
+
+  .rp-state-overview .rp-entry-list & {
+    margin: var(--spacing-02) var(--spacing-03);
+
+    &:first-child {
+      margin-top: 0;
+      padding-top: var(--spacing-02);
+    }
+  }
+}
+
+.rp-comment-content {
+  margin: 0;
+  color: var(--rp-type-darkgrey);
+  overflow-x: auto; // Long words, like links can overflow without this.
+  white-space: pre-wrap;
+}
+
+.rp-comment-resolver {
+  color: $rp-type-blue;
+}
+
+.rp-comment-resolver-content {
+  font-style: italic;
+  margin: 0;
+}
+
+.rp-comment-reply {
+  padding: 0 var(--spacing-03);
+}
+
+.rp-add-comment-btn,
+.rp-bulk-actions-btn {
+  @extend %rp-button;
+
+  padding: 5px 10px;
+  border-radius: var(--border-radius-base);
+
+  .material-symbols {
+    font-size: inherit;
+    vertical-align: middle;
+    font-weight: bold;
+  }
+}
+
+.rp-bulk-actions-btn {
+  border-radius: 0;
+
+  &:first-child {
+    border-top-left-radius: var(--border-radius-base);
+    border-bottom-left-radius: var(--border-radius-base);
+  }
+
+  &:last-child {
+    border-top-right-radius: var(--border-radius-base);
+    border-bottom-right-radius: var(--border-radius-base);
+    margin-left: 1px;
+  }
+}
+
+.rp-new-comment {
+  padding: var(--spacing-03);
+}
+
+.rp-comment-input {
+  display: block;
+  width: 100%;
+  font-size: var(--rp-base-font-size);
+  padding: var(--spacing-01) var(--spacing-03);
+  border-radius: 3px;
+  border: solid 1px var(--rp-border-grey);
+  resize: vertical;
+  color: var(--rp-type-darkgrey);
+  background-color: var(--white);
+  margin: var(--spacing-02) 0;
+  overflow-x: hidden;
+  min-height: 3em;
+  max-height: 400px;
+}
+
+.rp-icon-delete {
+  display: inline-block;
+  line-height: 1;
+  font-style: normal;
+  font-size: 0.8em;
+  text-decoration: line-through;
+  font-weight: var(--rp-semibold-weight);
+
+  &::before {
+    content: 'Ab';
+  }
+}
+
+.rp-resolved-comment {
+  border-left: solid var(--rp-entry-ribbon-width) $rp-yellow;
+  border-radius: var(--border-radius-base);
+  background-color: var(--white);
+  margin-bottom: var(--spacing-03);
+}
+
+.rp-resolved-comment-context {
+  background-color: color.adjust($rp-yellow, $lightness: 35%);
+  padding: var(--spacing-02) var(--spacing-03);
+}
+
+.rp-resolved-comment-context-file {
+  font-weight: var(--rp-semibold-weight);
+}
+
+.rp-resolved-comment-context-quote {
+  color: #000;
+  font-family: $font-family-monospace;
+  margin: 0;
+}
+
+.rp-entry-callout {
+  transition:
+    top var(--rp-entry-animation-speed),
+    height var(--rp-entry-animation-speed);
+
+  .rp-state-current-file & {
+    position: absolute;
+    border-top: 1px solid grey;
+    border-right: 1px dashed grey;
+
+    &::after {
+      content: '';
+      position: absolute;
+      top: -1px;
+      left: 3px;
+      bottom: 0;
+      border-bottom: 1px solid grey;
+    }
+  }
+
+  .rp-state-current-file-expanded & {
+    width: 3px;
+
+    &::after {
+      width: 3px;
+    }
+  }
+
+  .rp-state-current-file-mini & {
+    width: 1px;
+
+    &::after {
+      width: 1px;
+    }
+  }
+
+  .rp-state-overview & {
+    display: none;
+  }
+
+  .rp-state-current-file &-inverted {
+    border-top: none;
+    border-bottom: 1px solid grey;
+
+    &::after {
+      top: 0;
+      bottom: -1px;
+      border-top: 1px solid grey;
+      border-bottom: none;
+    }
+  }
+
+  .rp-state-current-file &-insert {
+    border-color: var(--rp-green);
+
+    &::after {
+      border-color: var(--rp-green);
+    }
+  }
+
+  .rp-state-current-file &-delete {
+    border-color: var(--rp-red);
+
+    &::after {
+      border-color: var(--rp-red);
+    }
+  }
+
+  .rp-state-current-file &-comment {
+    border-color: $rp-yellow;
+
+    &::after {
+      border-color: $rp-yellow;
+    }
+  }
+
+  .rp-size-mini &-add-comment {
+    display: none;
+  }
+}
+
+.rp-overview-file-header {
+  padding: var(--spacing-01) var(--spacing-03);
+  border-top: solid 1px var(--rp-border-grey);
+  border-bottom: solid 1px var(--rp-border-grey);
+  background-color: var(--rp-bg-dim-blue);
+  margin-top: var(--spacing-05);
+  font-weight: var(--rp-semibold-weight);
+  text-align: center;
+  cursor: pointer;
+}
+
+.rp-overview-file-num-entries {
+  font-weight: normal;
+  font-size: 0.9em;
+}
+
+.rp-overview-file-header-collapse {
+  float: left;
+
+  .material-symbols {
+    font-size: var(--font-size-02);
+  }
+}
+
+.rp-overview-file-entries {
+  overflow: hidden;
+}
+
+.rp-comment-wrapper {
+  transition: 0.35s opacity ease-out 0.2s;
+
+  &-resolving {
+    opacity: 0;
+  }
+}
+
+.rp-nav {
+  display: none;
+  flex-shrink: 0;
+
+  .rp-size-expanded & {
+    display: flex;
+  }
+
+  .rp-state-current-file & {
+    position: absolute;
+    bottom: 0;
+  }
+
+  width: 100%;
+  font-size: var(--rp-icon-large-size);
+  text-align: center;
+  background-color: var(--rp-bg-dim-blue);
+  border-top: solid 1px var(--rp-border-grey);
+  z-index: 2;
+}
+
+.rp-nav-item {
+  display: block;
+  color: color.adjust($rp-type-blue, $lightness: 25%);
+  flex: 0 0 50%;
+  border-top: solid 3px transparent;
+  border-right: 0;
+  border-bottom: 0;
+  border-left: 0;
+  background: none;
+  padding-bottom: var(--spacing-01);
+
+  &:hover,
+  &:focus {
+    text-decoration: none;
+    color: $rp-type-blue;
+  }
+
+  &-active {
+    color: $rp-type-blue;
+    border-top: solid 3px $rp-highlight-blue;
+  }
+}
+
+.rp-nav-label {
+  display: block;
+  font-size: var(--rp-base-font-size);
+}
+
 .review-icon {
   display: inline-block;
   background-image: var(--review-icon);
@@ -77,8 +833,8 @@ $rp-type-blue: #6b7797;
   color: color.adjust($rp-type-blue, $lightness: 25%);
   border: solid 1px var(--rp-border-grey);
   border-radius: var(--border-radius-base);
-  padding: 0 var(--spacing-02);
   display: block;
+  padding: 0;
   height: 22px;
   width: 22px;
   line-height: 1.4;
@@ -89,3 +845,292 @@ $rp-type-blue: #6b7797;
     color: $rp-type-blue;
   }
 }
+
+.resolved-comments-backdrop {
+  display: none;
+  position: fixed;
+  inset: 0;
+
+  &-visible {
+    display: block;
+  }
+}
+
+.resolved-comments-dropdown {
+  display: none;
+  position: absolute;
+  width: 300px;
+  left: -150px;
+  max-height: calc(100vh - 100px);
+  margin-top: calc(var(--rp-entry-arrow-width) * 1.5);
+  margin-left: 1em;
+  background-color: $rp-bg-blue;
+  text-align: left;
+  align-items: stretch;
+  justify-content: center;
+  border-radius: var(--border-radius-base);
+  box-shadow: 0 0 20px 10px rgba(0 0 0 / 30%);
+  z-index: 1;
+
+  &::before {
+    content: '';
+
+    @include triangle(
+      top,
+      $rp-entry-arrow-width * 3,
+      $rp-entry-arrow-width * 1.5,
+      $rp-bg-blue
+    );
+
+    top: calc(var(--rp-entry-ribbon-width) * -2);
+    left: 50%;
+    margin-left: calc(var(--rp-entry-arrow-width) * -0.75);
+  }
+
+  &-open {
+    display: flex;
+  }
+}
+
+.resolved-comments-scroller {
+  flex: 0 0 auto; // Can't use 100% in the flex-basis key here, IE won't account for padding.
+  width: 100%; // We need to set the width explicitly, as flex-basis won't work.
+  padding: var(--spacing-03);
+  overflow-y: auto;
+  max-height: calc(
+    100vh - 100px
+  ); // We also need to explicitly set the max-height, IE won't compute the flex-determined height.
+}
+
+.rp-collapse-toggle {
+  padding: var(--spacing-01) 0;
+  color: $rp-type-blue;
+  font-weight: var(--rp-semibold-weight);
+  border: 0;
+  background-color: transparent;
+
+  &:hover,
+  &:focus {
+    color: color.adjust($rp-type-blue, $lightness: -5%);
+    text-decoration: none;
+  }
+}
+
+.rp-track-changes-indicator {
+  display: block;
+  padding: 5px 10px;
+  background-color: rgba(240 240 240 / 90%);
+  color: $rp-type-blue;
+  text-align: center;
+  border-bottom-left-radius: var(--border-radius-base);
+  white-space: nowrap;
+
+  &.rp-track-changes-indicator-on-dark {
+    background-color: rgba(88 88 88 / 80%);
+    color: var(--white);
+
+    &:hover,
+    &:focus {
+      background-color: rgba(88 88 88 / 100%);
+      color: var(--white);
+    }
+  }
+
+  &:hover,
+  &:focus {
+    outline: 0;
+    text-decoration: none;
+    background-color: rgba(240 240 240 / 100%);
+    color: $rp-type-blue;
+  }
+}
+
+.review-panel-toggler {
+  display: none;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  width: 10px;
+  opacity: 0.5;
+  color: $rp-highlight-blue;
+  z-index: 1;
+  background-color: transparent;
+  transition: background 0.1s;
+  flex-direction: column;
+  padding: 0;
+  border: 0;
+
+  .rp-size-mini &,
+  .rp-size-expanded & {
+    display: flex;
+  }
+
+  .rp-size-expanded & {
+    .review-panel-toggler-icon .material-symbols {
+      transform: translateX(-50%) rotate(180deg);
+    }
+  }
+
+  &:hover,
+  &:focus {
+    color: $rp-highlight-blue;
+    background-color: var(--white);
+  }
+
+  .review-panel-toggler-icon {
+    position: sticky;
+    top: 50%;
+    bottom: 50%;
+    width: 100%;
+    overflow: hidden;
+
+    .material-symbols {
+      transform: translateX(-50%);
+      margin-left: 50%;
+    }
+  }
+}
+
+.review-panel {
+  position: relative;
+  z-index: 6;
+  flex-shrink: 0;
+  background-color: $rp-bg-blue;
+  border-left: solid 0 var(--rp-border-grey);
+  font-family: $font-family-base;
+  line-height: $line-height-base;
+  font-size: var(--rp-base-font-size);
+  color: $rp-type-blue;
+  box-sizing: content-box;
+
+  .rp-size-expanded & {
+    display: flex;
+    flex-direction: column;
+    width: var(--review-panel-width);
+    overflow: visible;
+    border-left-width: 1px;
+  }
+
+  .rp-size-mini & {
+    width: var(--review-off-width);
+    z-index: 6;
+    border-left-width: 1px;
+  }
+
+  .review-panel-toolbar-collapse-button {
+    display: inline-flex;
+    align-items: center;
+    border: none;
+    background: none;
+    padding: 0;
+    color: $rp-type-blue;
+  }
+
+  &.rp-current-file-container {
+    display: none;
+
+    .rp-size-mini &,
+    .rp-size-expanded & {
+      display: block;
+    }
+
+    .review-panel-toolbar {
+      position: sticky;
+      top: 0;
+    }
+
+    .rp-nav {
+      position: sticky;
+      bottom: 0;
+    }
+  }
+
+  .rp-entry-list-react {
+    position: relative;
+    overflow: hidden;
+
+    .rp-size-mini & {
+      overflow: visible;
+    }
+  }
+
+  .rp-state-current-file & {
+    .review-panel-tools {
+      display: flex;
+      flex-direction: column;
+      justify-content: space-between;
+      position: absolute;
+      inset: 0;
+    }
+  }
+
+  .rp-state-overview & {
+    position: sticky;
+    top: 0;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+  }
+}
+
+.rp-floating-entry {
+  position: absolute;
+  font-size: var(--rp-base-font-size);
+  color: $rp-type-blue;
+}
+
+.rp-floating-entry,
+.review-panel {
+  .rp-entry-metadata {
+    button {
+      padding: 0;
+      border: 0;
+      background-color: transparent;
+      color: var(--bg-info-01);
+
+      &:hover,
+      &:focus {
+        text-decoration: underline;
+      }
+    }
+  }
+}
+
+.rp-in-editor-widgets {
+  position: sticky;
+  top: 0;
+  right: 0;
+  font-size: 0.6875rem; // 11px
+  z-index: 2;
+  font-family: $font-family-sans-serif;
+
+  .rp-in-editor-widgets-inner {
+    position: absolute;
+    top: 0;
+    right: 0;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .rp-track-changes-indicator {
+    border: 0;
+  }
+
+  .rp-add-comment-btn,
+  .rp-bulk-actions-btn {
+    white-space: nowrap;
+    border-radius: 0;
+
+    &:last-child {
+      border-bottom-left-radius: var(--border-radius-base);
+    }
+  }
+}
+
+.track-changes-indicator-circle {
+  display: inline-block;
+  width: 10px;
+  height: 10px;
+  border-radius: 100%;
+  background-color: var(--bg-accent-01);
+}

+ 8 - 0
services/web/frontend/stylesheets/core/utilities.less

@@ -84,3 +84,11 @@
   position: absolute;
   z-index: 1;
 }
+
+.d-flex {
+  display: flex !important;
+}
+
+.justify-content-center {
+  justify-content: center !important;
+}