Merge pull request #20008 from overleaf/ae-review-panel-empty-state

Improve calculations of empty state, mini state and sizes variables in review panel

GitOrigin-RevId: 41bcb3b67c9f0019c11b4de0e4590b0407e04e66
This commit is contained in:
Alf Eaton 2024-08-22 10:44:22 +01:00 committed by Copybot
parent 989c48978a
commit e61eb1b220
11 changed files with 341 additions and 245 deletions

View file

@ -4,7 +4,6 @@ import {
useCodeMirrorViewContext, useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-editor' } from '@/features/source-editor/components/codemirror-editor'
import { EditorSelection } from '@codemirror/state' import { EditorSelection } from '@codemirror/state'
import { PANEL_WIDTH } from './review-panel'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context' import { useThreadsActionsContext } from '../context/threads-context'
@ -49,7 +48,7 @@ export const ReviewPanelAddComment: FC = () => {
return ( return (
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', width: PANEL_WIDTH }} className="review-panel-add-comment-form"
ref={handleElement} ref={handleElement}
> >
{/* eslint-disable-next-line jsx-a11y/no-autofocus */} {/* eslint-disable-next-line jsx-a11y/no-autofocus */}

View file

@ -4,19 +4,30 @@ import { memo } from 'react'
import ReviewPanel from './review-panel' import ReviewPanel from './review-panel'
import { useLayoutContext } from '@/shared/context/layout-context' import { useLayoutContext } from '@/shared/context/layout-context'
import { useRangesContext } from '../context/ranges-context' import { useRangesContext } from '../context/ranges-context'
import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
function ReviewPanelContainer() { function ReviewPanelContainer() {
const view = useCodeMirrorViewContext() const view = useCodeMirrorViewContext()
const ranges = useRangesContext() const ranges = useRangesContext()
const threads = useThreadsContext()
const { reviewPanelOpen } = useLayoutContext() const { reviewPanelOpen } = useLayoutContext()
const mini = !reviewPanelOpen && !!ranges?.total if (!view) {
if (!view || (!reviewPanelOpen && !mini)) {
return null return null
} }
return ReactDOM.createPortal(<ReviewPanel mini={mini} />, view.scrollDOM) // the full-width review panel
if (reviewPanelOpen) {
return ReactDOM.createPortal(<ReviewPanel />, view.scrollDOM)
}
// the mini review panel
if (hasActiveRange(ranges, threads)) {
return ReactDOM.createPortal(<ReviewPanel mini />, view.scrollDOM)
}
return null
} }
export default memo(ReviewPanelContainer) export default memo(ReviewPanelContainer)

View file

@ -16,7 +16,6 @@ import {
DeleteOperation, DeleteOperation,
EditOperation, EditOperation,
} from '../../../../../types/change' } from '../../../../../types/change'
import { editorVerticalTopPadding } from '@/features/source-editor/extensions/vertical-overflow'
import { import {
useCodeMirrorStateContext, useCodeMirrorStateContext,
useCodeMirrorViewContext, useCodeMirrorViewContext,
@ -27,18 +26,14 @@ import { isDeleteChange, isInsertChange } from '@/utils/operations'
import Icon from '@/shared/components/icon' import Icon from '@/shared/components/icon'
import { positionItems } from '../utils/position-items' import { positionItems } from '../utils/position-items'
import { canAggregate } from '../utils/can-aggregate' import { canAggregate } from '../utils/can-aggregate'
import { isInViewport } from '../utils/is-in-viewport'
import ReviewPanelEmptyState from './review-panel-empty-state' import ReviewPanelEmptyState from './review-panel-empty-state'
import useEventListener from '@/shared/hooks/use-event-listener' import useEventListener from '@/shared/hooks/use-event-listener'
import { hasActiveRange } from '@/features/review-panel-new/utils/has-active-range'
type Positions = Map<string, number> type AggregatedRanges = {
type Aggregates = Map<string, Change<DeleteOperation>>
type RangesWithPositions = {
changes: Change<EditOperation>[] changes: Change<EditOperation>[]
comments: Change<CommentOperation>[] comments: Change<CommentOperation>[]
positions: Positions aggregates: Map<string, Change<DeleteOperation>>
aggregates: Aggregates
} }
const ReviewPanelCurrentFile: FC = () => { const ReviewPanelCurrentFile: FC = () => {
@ -47,24 +42,7 @@ const ReviewPanelCurrentFile: FC = () => {
const threads = useThreadsContext() const threads = useThreadsContext()
const state = useCodeMirrorStateContext() const state = useCodeMirrorStateContext()
const [rangesWithPositions, setRangesWithPositions] = const [aggregatedRanges, setAggregatedRanges] = useState<AggregatedRanges>()
useState<RangesWithPositions>()
const contentRect = view.contentDOM.getBoundingClientRect()
const editorPaddingTop = editorVerticalTopPadding(view)
const topDiff = contentRect.top - editorPaddingTop
const docLength = state.doc.length
const screenPosition = useCallback(
(change: Change): number | undefined => {
const pos = Math.min(change.op.p, docLength)
const coords = view.coordsAtPos(pos)
return coords ? Math.round(coords.top - topDiff) : undefined
},
[docLength, topDiff, view]
)
const selectionCoords = useMemo( const selectionCoords = useMemo(
() => () =>
@ -85,7 +63,7 @@ const ReviewPanelCurrentFile: FC = () => {
) )
if (extents) { if (extents) {
previousFocusedItem.current = extents.focusedItemIndex previousFocusedItem.current = extents.activeItemIndex
} }
} }
}, []) }, [])
@ -124,62 +102,84 @@ const ReviewPanelCurrentFile: FC = () => {
} }
}, [updatePositions]) }, [updatePositions])
const buildEntries = useCallback(() => { const buildAggregatedRanges = useCallback(() => {
if (ranges) { if (ranges) {
const output: AggregatedRanges = {
aggregates: new Map(),
changes: [],
comments: [],
}
let precedingChange: Change<EditOperation> | null = null
for (const change of ranges.changes) {
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
output.aggregates.set(precedingChange.id, change)
} else {
output.changes.push(change)
}
precedingChange = change
}
if (threads) {
for (const comment of ranges.comments) {
if (!threads[comment.op.t]?.resolved) {
output.comments.push(comment)
}
}
}
setAggregatedRanges(output)
}
}, [threads, ranges])
useEffect(() => {
buildAggregatedRanges()
}, [buildAggregatedRanges])
useEventListener('editor:viewport-changed', buildAggregatedRanges)
const [positions, setPositions] = useState<Map<string, number>>(new Map())
const positionsRef = useRef<Map<string, number>>(new Map())
useEffect(() => {
if (aggregatedRanges) {
view.requestMeasure({ view.requestMeasure({
key: 'review-panel-position', key: 'review-panel-position',
read(view): RangesWithPositions { read(view) {
const isVisible = isInViewport(view) const contentRect = view.contentDOM.getBoundingClientRect()
const docLength = view.state.doc.length
const output: RangesWithPositions = { const screenPosition = (change: Change): number | undefined => {
positions: new Map(), const pos = Math.min(change.op.p, docLength) // TODO: needed?
aggregates: new Map(), const coords = view.coordsAtPos(pos)
changes: [],
comments: [], return coords ? Math.round(coords.top - contentRect.top) : undefined
} }
let precedingChange: Change<EditOperation> | null = null for (const change of aggregatedRanges.changes) {
const position = screenPosition(change)
for (const change of ranges.changes) { if (position) {
if (isVisible(change)) { positionsRef.current.set(change.id, position)
if (
precedingChange &&
isInsertChange(precedingChange) &&
isDeleteChange(change) &&
canAggregate(change, precedingChange)
) {
output.aggregates.set(precedingChange.id, change)
} else {
output.changes.push(change)
const position = screenPosition(change)
if (position) {
output.positions.set(change.id, position)
}
}
}
precedingChange = change
}
if (threads) {
for (const comment of ranges.comments) {
if (isVisible(comment)) {
output.comments.push(comment)
if (!threads[comment.op.t]?.resolved) {
const position = screenPosition(comment)
if (position) {
output.positions.set(comment.id, position)
}
}
}
} }
} }
return output for (const comment of aggregatedRanges.comments) {
const position = screenPosition(comment)
if (position) {
positionsRef.current.set(comment.id, position)
}
}
}, },
write(positionedRanges) { write() {
setRangesWithPositions(positionedRanges) setPositions(positionsRef.current)
window.setTimeout(() => { window.setTimeout(() => {
containerRef.current?.dispatchEvent( containerRef.current?.dispatchEvent(
new Event('review-panel:position') new Event('review-panel:position')
@ -188,28 +188,22 @@ const ReviewPanelCurrentFile: FC = () => {
}, },
}) })
} }
}, [screenPosition, threads, view, ranges]) }, [view, aggregatedRanges])
useEffect(() => { const showEmptyState = useMemo(
buildEntries() () => hasActiveRange(ranges, threads) === false,
}, [buildEntries]) [ranges, threads]
)
useEventListener('editor:viewport-changed', buildEntries) if (!aggregatedRanges) {
if (!rangesWithPositions) {
return null return null
} }
const showEmptyState =
threads &&
rangesWithPositions.changes.length === 0 &&
rangesWithPositions.comments.length === 0
return ( return (
<div ref={handleContainer}> <div ref={handleContainer}>
{selectionCoords && ( {selectionCoords && (
<div <div
className="review-panel-entry" className="review-panel-entry review-panel-entry-action"
style={{ position: 'absolute' }} style={{ position: 'absolute' }}
data-top={selectionCoords.top + view.scrollDOM.scrollTop - 70} data-top={selectionCoords.top + view.scrollDOM.scrollTop - 70}
data-pos={state.selection.main.head} data-pos={state.selection.main.head}
@ -225,22 +219,28 @@ const ReviewPanelCurrentFile: FC = () => {
{showEmptyState && <ReviewPanelEmptyState />} {showEmptyState && <ReviewPanelEmptyState />}
{rangesWithPositions.changes.map(change => ( {aggregatedRanges.changes.map(
<ReviewPanelChange change =>
key={change.id} positions.has(change.id) && (
change={change} <ReviewPanelChange
top={rangesWithPositions.positions.get(change.id)} key={change.id}
aggregate={rangesWithPositions.aggregates.get(change.id)} change={change}
/> top={positions.get(change.id)}
))} aggregate={aggregatedRanges.aggregates.get(change.id)}
/>
)
)}
{rangesWithPositions.comments.map(comment => ( {aggregatedRanges.comments.map(
<ReviewPanelComment comment =>
key={comment.id} positions.has(comment.id) && (
comment={comment} <ReviewPanelComment
top={rangesWithPositions.positions.get(comment.id)} key={comment.id}
/> comment={comment}
))} top={positions.get(comment.id)}
/>
)
)}
</div> </div>
) )
} }

View file

@ -6,16 +6,13 @@ import { Button } from 'react-bootstrap'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import { useLayoutContext } from '@/shared/context/layout-context' import { useLayoutContext } from '@/shared/context/layout-context'
const ReviewPanelHeader: FC<{ const ReviewPanelHeader: FC = () => {
top: number
width: number
}> = ({ top, width }) => {
const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] = const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
useState(false) useState(false)
const { setReviewPanelOpen } = useLayoutContext() const { setReviewPanelOpen } = useLayoutContext()
return ( return (
<div className="review-panel-header" style={{ top, width }}> <div className="review-panel-header">
<div className="review-panel-heading"> <div className="review-panel-heading">
<div className="review-panel-label">Review</div> <div className="review-panel-label">Review</div>
<Button <Button

View file

@ -1,4 +1,4 @@
import { FC, useMemo } from 'react' import { FC, Fragment, useMemo } from 'react'
import { useFileTreeData } from '@/shared/context/file-tree-data-context' import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { Ranges, useRangesContext } from '../context/ranges-context' import { Ranges, useRangesContext } from '../context/ranges-context'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -59,14 +59,10 @@ export const ReviewPanelOverview: FC = () => {
const ranges = rangesForDocs.get(doc.doc.id) const ranges = rangesForDocs.get(doc.doc.id)
return ( return (
ranges && ( ranges && (
<> <Fragment key={doc.doc.id}>
<ReviewPanelOverviewFile <ReviewPanelOverviewFile doc={doc} ranges={ranges} />
key={doc.doc.id}
doc={doc}
ranges={ranges}
/>
<div className="review-panel-overfile-divider" /> <div className="review-panel-overfile-divider" />
</> </Fragment>
) )
) )
})} })}

View file

@ -1,69 +1,32 @@
import { FC, memo, useState } from 'react' import { FC, memo, useState } from 'react'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '@/features/source-editor/components/codemirror-editor'
import ReviewPanelTabs from './review-panel-tabs' import ReviewPanelTabs from './review-panel-tabs'
import ReviewPanelHeader from './review-panel-header' import ReviewPanelHeader from './review-panel-header'
import ReviewPanelCurrentFile from './review-panel-current-file' import ReviewPanelCurrentFile from './review-panel-current-file'
import { ReviewPanelOverview } from './review-panel-overview' import { ReviewPanelOverview } from './review-panel-overview'
import classnames from 'classnames' import classnames from 'classnames'
import { useReviewPanelStyles } from '@/features/review-panel-new/hooks/use-review-panel-styles'
export type SubView = 'cur_file' | 'overview' export type SubView = 'cur_file' | 'overview'
export const PANEL_WIDTH = 230 const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
export const PANEL_MINI_WIDTH = 20
const ReviewPanel: FC<{ mini: boolean }> = ({ mini }) => {
const view = useCodeMirrorViewContext()
// eslint-disable-next-line no-unused-vars
const _state = useCodeMirrorStateContext() // needs to update on editor state changes
const [subView, setSubView] = useState<SubView>('cur_file') const [subView, setSubView] = useState<SubView>('cur_file')
const contentRect = view.contentDOM.getBoundingClientRect() const style = useReviewPanelStyles(mini)
const scrollRect = view.scrollDOM.getBoundingClientRect()
const className = classnames('review-panel-new', 'review-panel-container', {
'review-panel-mini': mini,
'review-panel-subview-overview': subView === 'overview',
})
return ( return (
<div <div className={className} style={style}>
className="review-panel-container" <div className="review-panel-inner">
style={{ {!mini && <ReviewPanelHeader />}
overflowY: subView === 'overview' ? 'hidden' : undefined,
position: subView === 'overview' ? 'sticky' : 'relative',
top: subView === 'overview' ? 0 : undefined,
}}
>
<div
className={classnames('review-panel-new', {
'review-panel-mini': mini,
})}
style={{
flexShrink: 0,
minHeight: subView === 'cur_file' ? contentRect.height : 'auto',
height: subView === 'overview' ? '100%' : undefined,
overflow: subView === 'overview' ? 'hidden' : undefined,
width: mini ? PANEL_MINI_WIDTH : PANEL_WIDTH,
}}
>
{!mini && (
<ReviewPanelHeader top={scrollRect.top - 40} width={PANEL_WIDTH} />
)}
{subView === 'cur_file' && <ReviewPanelCurrentFile />} {subView === 'cur_file' && <ReviewPanelCurrentFile />}
{subView === 'overview' && <ReviewPanelOverview />} {subView === 'overview' && <ReviewPanelOverview />}
<div <div className="review-panel-footer">
className="review-panel-footer"
style={{
position: 'fixed',
top: scrollRect.bottom - 66,
zIndex: 1,
background: '#fafafa',
borderTop: 'solid 1px #d9d9d9',
width: PANEL_WIDTH,
display: mini ? 'none' : 'flex',
}}
>
<ReviewPanelTabs subView={subView} setSubView={setSubView} /> <ReviewPanelTabs subView={subView} setSubView={setSubView} />
</div> </div>
</div> </div>

View file

@ -0,0 +1,54 @@
import { CSSProperties, useCallback, useEffect, useState } from 'react'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-editor'
export const useReviewPanelStyles = (mini: boolean) => {
const view = useCodeMirrorViewContext()
const [styles, setStyles] = useState<CSSProperties>()
const updateScrollDomVariables = useCallback((element: HTMLDivElement) => {
const { top, bottom } = element.getBoundingClientRect()
setStyles(value => ({
...value,
'--review-panel-top': `${top}px`,
'--review-panel-bottom': `${bottom}px`,
}))
}, [])
const updateContentDomVariables = useCallback((element: HTMLDivElement) => {
const { height } = element.getBoundingClientRect()
setStyles(value => ({
...value,
'--review-panel-height': `${height}px`,
}))
}, [])
useEffect(() => {
setStyles(value => ({
...value,
'--review-panel-width': mini ? '22px' : '230px',
}))
}, [mini])
useEffect(() => {
if ('ResizeObserver' in window) {
const scrollDomObserver = new window.ResizeObserver(entries =>
updateScrollDomVariables(entries[0]?.target as HTMLDivElement)
)
scrollDomObserver.observe(view.scrollDOM)
const contentDomObserver = new window.ResizeObserver(entries =>
updateContentDomVariables(entries[0]?.target as HTMLDivElement)
)
contentDomObserver.observe(view.contentDOM)
return () => {
scrollDomObserver.disconnect()
contentDomObserver.disconnect()
}
}
}, [view, updateScrollDomVariables, updateContentDomVariables])
return styles
}

View file

@ -0,0 +1,25 @@
import { Ranges } from '@/features/review-panel-new/context/ranges-context'
import { Threads } from '@/features/review-panel-new/context/threads-context'
export const hasActiveRange = (
ranges: Ranges | undefined,
threads: Threads | undefined
): boolean | undefined => {
if (!ranges || !threads) {
// data isn't loaded yet
return undefined
}
if (ranges.changes.length > 0) {
// at least one tracked change
return true
}
for (const thread of Object.values(threads)) {
if (!thread.resolved) {
return true
}
}
return false
}

View file

@ -1,7 +0,0 @@
import { EditorView } from '@codemirror/view'
import { Change } from '../../../../../types/change'
export const isInViewport =
(view: EditorView) =>
(change: Change): boolean =>
change.op.p >= view.viewport.from && change.op.p <= view.viewport.to

View file

@ -15,34 +15,44 @@ export const positionItems = debounce(
return return
} }
let focusedItemIndex = items.findIndex(item => let activeItemIndex = items.findIndex(item =>
item.classList.contains('review-panel-entry-focused') item.classList.contains('review-panel-entry-action')
) )
if (focusedItemIndex === -1) {
if (activeItemIndex === -1) {
// if there is no action available
// check if there is a focused entry
activeItemIndex = items.findIndex(item =>
item.classList.contains('review-panel-entry-focused')
)
}
if (activeItemIndex === -1) {
// if entry was not focused manually // if entry was not focused manually
// check if there is an entry in selection and use that as the focused item // check if there is an entry in selection and use that as the focused item
focusedItemIndex = items.findIndex(item => activeItemIndex = items.findIndex(item =>
item.classList.contains('review-panel-entry-highlighted') item.classList.contains('review-panel-entry-highlighted')
) )
} }
if (focusedItemIndex === -1) {
focusedItemIndex = previousFocusedItemIndex if (activeItemIndex === -1) {
activeItemIndex = previousFocusedItemIndex
} }
const focusedItem = items[focusedItemIndex] const activeItem = items[activeItemIndex]
if (!focusedItem) { if (!activeItem) {
return return
} }
const focusedItemTop = getTopPosition(focusedItem, focusedItemIndex === 0) const activeItemTop = getTopPosition(activeItem, activeItemIndex === 0)
focusedItem.style.top = `${focusedItemTop}px` activeItem.style.top = `${activeItemTop}px`
focusedItem.style.visibility = 'visible' activeItem.style.visibility = 'visible'
const focusedItemRect = focusedItem.getBoundingClientRect() const focusedItemRect = activeItem.getBoundingClientRect()
// above the focused item // above the active item
let topLimit = focusedItemTop let topLimit = activeItemTop
for (let i = focusedItemIndex - 1; i >= 0; i--) { for (let i = activeItemIndex - 1; i >= 0; i--) {
const item = items[i] const item = items[i]
const rect = item.getBoundingClientRect() const rect = item.getBoundingClientRect()
let top = getTopPosition(item, i === 0) let top = getTopPosition(item, i === 0)
@ -55,9 +65,9 @@ export const positionItems = debounce(
topLimit = top topLimit = top
} }
// below the focused item // below the active item
let bottomLimit = focusedItemTop + focusedItemRect.height let bottomLimit = activeItemTop + focusedItemRect.height
for (let i = focusedItemIndex + 1; i < items.length; i++) { for (let i = activeItemIndex + 1; i < items.length; i++) {
const item = items[i] const item = items[i]
const rect = item.getBoundingClientRect() const rect = item.getBoundingClientRect()
let top = getTopPosition(item, false) let top = getTopPosition(item, false)
@ -70,7 +80,7 @@ export const positionItems = debounce(
} }
return { return {
focusedItemIndex, activeItemIndex,
min: topLimit, min: topLimit,
max: bottomLimit, max: bottomLimit,
} }

View file

@ -1,17 +1,22 @@
.review-panel-container {
height: 100%;
flex-shrink: 0;
}
.review-panel-new { .review-panel-new {
z-index: 6; &.review-panel-container {
flex-shrink: 0; height: 100%;
background-color: @neutral-10; flex-shrink: 0;
border-left: solid 0 @neutral-20; position: relative;
font-family: @font-family-base; }
line-height: @line-height-base;
font-size: @font-size-01; .review-panel-inner {
box-sizing: content-box; z-index: 6;
flex-shrink: 0;
background-color: @neutral-10;
border-left: solid 0 @neutral-20;
font-family: @font-family-base;
line-height: @line-height-base;
font-size: @font-size-01;
box-sizing: content-box;
width: var(--review-panel-width);
min-height: var(--review-panel-height);
}
.review-panel-entry { .review-panel-entry {
background-color: white; background-color: white;
@ -38,6 +43,7 @@
margin-left: @spacing-01; margin-left: @spacing-01;
border: 1px solid @blue-50; border: 1px solid @blue-50;
} }
.review-panel-entry-header { .review-panel-entry-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -46,9 +52,11 @@
color: @blue; color: @blue;
font-size: 110%; font-size: 110%;
} }
.review-panel-entry-time { .review-panel-entry-time {
color: @content-secondary; color: @content-secondary;
} }
.review-panel-entry-actions { .review-panel-entry-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -73,16 +81,19 @@
} }
} }
} }
.review-panel-change-body { .review-panel-change-body {
display: flex; display: flex;
align-items: center; align-items: center;
color: @content-secondary; color: @content-secondary;
gap: @spacing-02; gap: @spacing-02;
} }
.review-panel-content-highlight { .review-panel-content-highlight {
color: @content-primary; color: @content-primary;
text-decoration: none; text-decoration: none;
} }
del.review-panel-content-highlight { del.review-panel-content-highlight {
text-decoration: line-through; text-decoration: line-through;
} }
@ -92,14 +103,17 @@
padding: @spacing-02; padding: @spacing-02;
font-size: 16px; font-size: 16px;
} }
.review-panel-entry-icon-accept { .review-panel-entry-icon-accept {
background-color: @green-10; background-color: @green-10;
color: @green-50; color: @green-50;
} }
.review-panel-entry-icon-reject { .review-panel-entry-icon-reject {
background-color: @red-10; background-color: @red-10;
color: @red-50; color: @red-50;
} }
.review-panel-entry-icon-changed { .review-panel-entry-icon-changed {
background-color: @neutral-20; background-color: @neutral-20;
color: @content-secondary; color: @content-secondary;
@ -107,7 +121,8 @@
.review-panel-header { .review-panel-header {
position: fixed; position: fixed;
top: 0; top: calc(var(--review-panel-top) - 40px);
width: var(--review-panel-width);
z-index: 2; z-index: 2;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -291,19 +306,22 @@
display: flex; display: flex;
gap: @spacing-04; gap: @spacing-04;
} }
.review-panel-comment { .review-panel-comment {
flex-grow: 1; flex-grow: 1;
} }
.review-panel-comment-reply-divider { .review-panel-comment-reply-divider {
border-left: 2px solid @yellow-20; border-left: 2px solid @yellow-20;
} }
.review-panel-comment-body { .review-panel-comment-body {
font-size: @font-size-02; font-size: @font-size-02;
color: @content-primary; color: @content-primary;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.review-panel-content-expandable  { .review-panel-content-expandable {
display: -webkit-box; display: -webkit-box;
text-overflow: ellipsis; text-overflow: ellipsis;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@ -311,6 +329,7 @@
line-clamp: 3; line-clamp: 3;
overflow: hidden; overflow: hidden;
} }
.review-panel-content-expanded { .review-panel-content-expanded {
display: block; display: block;
} }
@ -330,6 +349,12 @@
max-height: 400px; max-height: 400px;
} }
.review-panel-add-comment-form {
display: flex;
flex-direction: column;
width: var(--review-panel-width);
}
.review-panel-empty-state { .review-panel-empty-state {
position: absolute; position: absolute;
top: 0; top: 0;
@ -419,46 +444,69 @@
} }
.review-panel-footer { .review-panel-footer {
position: fixed;
top: calc(var(--review-panel-bottom) - 66px);
width: var(--review-panel-width);
z-index: 2; z-index: 2;
} background: @rp-bg-dim-blue;
} border-top: 1px solid #d9d9d9;
.review-panel-new.review-panel-mini {
width: 22px !important;
overflow: visible !important;
.review-panel-entry {
margin-left: 2px;
background-color: transparent;
border: none;
}
.review-panel-entry-indicator {
position: absolute;
left: 0;
top: 7px;
display: flex; display: flex;
width: 16px;
height: 16px;
color: @content-secondary;
cursor: pointer;
} }
.review-panel-entry-content { &.review-panel-subview-overview {
display: none; &.review-panel-container {
background: white; overflow-y: hidden;
border: 1px solid @rp-border-grey; position: sticky;
border-radius: @border-radius-base-new;
width: 200px;
padding: @spacing-02;
}
.review-panel-entry:hover {
.review-panel-entry-content {
display: initial;
position: absolute;
left: -200px;
top: 0; top: 0;
} }
.review-panel-inner {
min-height: auto;
height: 100%;
overflow: hidden;
}
}
&.review-panel-mini {
overflow: visible !important;
.review-panel-entry {
margin-left: 2px;
background-color: transparent;
border: none;
}
.review-panel-entry-indicator {
position: absolute;
left: 0;
top: 7px;
display: flex;
width: 16px;
height: 16px;
color: @content-secondary;
cursor: pointer;
}
.review-panel-entry-content {
display: none;
background: white;
border: 1px solid @rp-border-grey;
border-radius: @border-radius-base-new;
width: 200px;
padding: @spacing-02;
}
.review-panel-entry:hover {
.review-panel-entry-content {
display: initial;
position: absolute;
left: -200px;
top: 0;
}
}
.review-panel-footer {
display: none;
}
} }
} }