mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Remove extra padding for off-screen review panel entries (#19841)
* Remove extra padding for off-screen review panel entries * use document.activeElement * cleanup editor padding cases * using isSelectionWithinOp * fix formatting * focusHandler function * 1px border for focused entry * use constants * using isFirstEntry GitOrigin-RevId: 4509f803b6cb907b40f1745a6fc7f3b1edfe145c
This commit is contained in:
parent
b3d9ea5ad3
commit
ad7abee39a
7 changed files with 111 additions and 88 deletions
|
@ -7,22 +7,20 @@ import {
|
||||||
} from '../../../../../types/change'
|
} from '../../../../../types/change'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-editor'
|
|
||||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||||
import { isFocused } from '../utils/is-focused'
|
|
||||||
import { Button } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import Tooltip from '@/shared/components/tooltip'
|
import Tooltip from '@/shared/components/tooltip'
|
||||||
import MaterialIcon from '@/shared/components/material-icon'
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
|
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
|
||||||
import { useChangesUsersContext } from '../context/changes-users-context'
|
import { useChangesUsersContext } from '../context/changes-users-context'
|
||||||
import { ReviewPanelChangeUser } from './review-panel-change-user'
|
import { ReviewPanelChangeUser } from './review-panel-change-user'
|
||||||
|
import { ReviewPanelEntry } from './review-panel-entry'
|
||||||
|
|
||||||
export const ReviewPanelChange = memo<{
|
export const ReviewPanelChange = memo<{
|
||||||
change: Change<EditOperation>
|
change: Change<EditOperation>
|
||||||
aggregate?: Change<DeleteOperation>
|
aggregate?: Change<DeleteOperation>
|
||||||
top?: number
|
top?: number
|
||||||
}>(({ change, aggregate, top }) => {
|
}>(({ change, aggregate, top }) => {
|
||||||
const state = useCodeMirrorStateContext()
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
const { acceptChanges, rejectChanges } = useRangesActionsContext()
|
||||||
const permissions = usePermissionsContext()
|
const permissions = usePermissionsContext()
|
||||||
|
@ -33,23 +31,16 @@ export const ReviewPanelChange = memo<{
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const focused = isFocused(change.op, state.selection.main)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ReviewPanelEntry
|
||||||
className={classnames('review-panel-entry', 'review-panel-entry-change', {
|
className={classnames('review-panel-entry-change', {
|
||||||
'review-panel-entry-focused': focused,
|
|
||||||
'review-panel-entry-insert': 'i' in change.op,
|
'review-panel-entry-insert': 'i' in change.op,
|
||||||
'review-panel-entry-delete': 'd' in change.op,
|
'review-panel-entry-delete': 'd' in change.op,
|
||||||
// TODO: aggregate
|
// TODO: aggregate
|
||||||
})}
|
})}
|
||||||
data-top={top}
|
top={top}
|
||||||
data-pos={change.op.p}
|
op={change.op}
|
||||||
style={{
|
position={change.op.p}
|
||||||
position: top === undefined ? 'relative' : 'absolute',
|
|
||||||
visibility: top === undefined ? 'visible' : 'hidden',
|
|
||||||
transition: 'top .3s, left .1s, right .1s',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="review-panel-entry-indicator">
|
<div className="review-panel-entry-indicator">
|
||||||
<MaterialIcon type="edit" className="review-panel-entry-icon" />
|
<MaterialIcon type="edit" className="review-panel-entry-icon" />
|
||||||
|
@ -166,7 +157,7 @@ export const ReviewPanelChange = memo<{
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ReviewPanelEntry>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
ReviewPanelChange.displayName = 'ReviewPanelChange'
|
ReviewPanelChange.displayName = 'ReviewPanelChange'
|
||||||
|
|
|
@ -6,11 +6,10 @@ import {
|
||||||
useThreadsActionsContext,
|
useThreadsActionsContext,
|
||||||
useThreadsContext,
|
useThreadsContext,
|
||||||
} from '../context/threads-context'
|
} from '../context/threads-context'
|
||||||
import { useCodeMirrorStateContext } from '@/features/source-editor/components/codemirror-editor'
|
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import { isFocused } from '../utils/is-focused'
|
|
||||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||||
import MaterialIcon from '@/shared/components/material-icon'
|
import MaterialIcon from '@/shared/components/material-icon'
|
||||||
|
import { ReviewPanelEntry } from './review-panel-entry'
|
||||||
|
|
||||||
export const ReviewPanelComment = memo<{
|
export const ReviewPanelComment = memo<{
|
||||||
comment: Change<CommentOperation>
|
comment: Change<CommentOperation>
|
||||||
|
@ -22,7 +21,6 @@ export const ReviewPanelComment = memo<{
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const threads = useThreadsContext()
|
const threads = useThreadsContext()
|
||||||
const { resolveThread, addMessage } = useThreadsActionsContext()
|
const { resolveThread, addMessage } = useThreadsActionsContext()
|
||||||
const state = useCodeMirrorStateContext()
|
|
||||||
|
|
||||||
const handleSubmitReply = useCallback(() => {
|
const handleSubmitReply = useCallback(() => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
|
@ -52,25 +50,14 @@ export const ReviewPanelComment = memo<{
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const focused = isFocused(comment.op, state.selection.main)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ReviewPanelEntry
|
||||||
className={classnames(
|
className={classnames('review-panel-entry-comment', {
|
||||||
'review-panel-entry',
|
'review-panel-entry-loaded': !!threads?.[comment.op.t],
|
||||||
'review-panel-entry-comment',
|
})}
|
||||||
{
|
top={top}
|
||||||
'review-panel-entry-loaded': !!threads?.[comment.op.t],
|
op={comment.op}
|
||||||
'review-panel-entry-focused': focused,
|
position={comment.op.p}
|
||||||
}
|
|
||||||
)}
|
|
||||||
data-top={top}
|
|
||||||
data-pos={comment.op.p}
|
|
||||||
style={{
|
|
||||||
position: top === undefined ? 'relative' : 'absolute',
|
|
||||||
visibility: top === undefined ? 'visible' : 'hidden',
|
|
||||||
transition: 'top .3s, left .1s, right .1s',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="review-panel-entry-indicator">
|
<div className="review-panel-entry-indicator">
|
||||||
<MaterialIcon type="edit" className="review-panel-entry-icon" />
|
<MaterialIcon type="edit" className="review-panel-entry-icon" />
|
||||||
|
@ -109,7 +96,7 @@ export const ReviewPanelComment = memo<{
|
||||||
|
|
||||||
{error && <div>{error.message}</div>}
|
{error && <div>{error.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ReviewPanelEntry>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
ReviewPanelComment.displayName = 'ReviewPanelComment'
|
ReviewPanelComment.displayName = 'ReviewPanelComment'
|
||||||
|
|
|
@ -16,10 +16,7 @@ import {
|
||||||
DeleteOperation,
|
DeleteOperation,
|
||||||
EditOperation,
|
EditOperation,
|
||||||
} from '../../../../../types/change'
|
} from '../../../../../types/change'
|
||||||
import {
|
import { editorVerticalTopPadding } from '@/features/source-editor/extensions/vertical-overflow'
|
||||||
editorOverflowPadding,
|
|
||||||
editorVerticalTopPadding,
|
|
||||||
} from '@/features/source-editor/extensions/vertical-overflow'
|
|
||||||
import {
|
import {
|
||||||
useCodeMirrorStateContext,
|
useCodeMirrorStateContext,
|
||||||
useCodeMirrorViewContext,
|
useCodeMirrorViewContext,
|
||||||
|
@ -77,50 +74,25 @@ const ReviewPanelCurrentFile: FC = () => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const ignoreNextUpdateRef = useRef(false)
|
|
||||||
const previousFocusedItem = useRef(0)
|
const previousFocusedItem = useRef(0)
|
||||||
|
|
||||||
const updatePositions = useCallback(() => {
|
const updatePositions = useCallback(() => {
|
||||||
if (ignoreNextUpdateRef.current) {
|
|
||||||
ignoreNextUpdateRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
const extents = positionItems(
|
const extents = positionItems(
|
||||||
containerRef.current,
|
containerRef.current,
|
||||||
view.scrollDOM as HTMLDivElement,
|
|
||||||
previousFocusedItem.current
|
previousFocusedItem.current
|
||||||
)
|
)
|
||||||
|
|
||||||
if (extents) {
|
if (extents) {
|
||||||
previousFocusedItem.current = extents.focusedItemIndex
|
previousFocusedItem.current = extents.focusedItemIndex
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
const top = extents.min < 0 ? -extents.min : 0
|
|
||||||
const bottom =
|
|
||||||
extents.max > contentRect.bottom
|
|
||||||
? extents.max - contentRect.bottom
|
|
||||||
: 0
|
|
||||||
|
|
||||||
const currentPadding = editorOverflowPadding(view)
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentPadding?.top !== top ||
|
|
||||||
currentPadding?.bottom !== bottom
|
|
||||||
) {
|
|
||||||
// ignoreNextUpdateRef.current = true
|
|
||||||
// view.dispatch(setVerticalOverflow({ top, bottom }))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [contentRect.bottom, view])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
updatePositions()
|
updatePositions()
|
||||||
}, 100)
|
}, 50)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { FC, useCallback, useState } from 'react'
|
||||||
|
import { AnyOperation } from '../../../../../types/change'
|
||||||
|
import {
|
||||||
|
useCodeMirrorStateContext,
|
||||||
|
useCodeMirrorViewContext,
|
||||||
|
} from '@/features/source-editor/components/codemirror-editor'
|
||||||
|
import { isSelectionWithinOp } from '../utils/is-selection-within-op'
|
||||||
|
import { EditorSelection } from '@codemirror/state'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
|
export const ReviewPanelEntry: FC<{
|
||||||
|
position: number
|
||||||
|
op: AnyOperation
|
||||||
|
top?: number
|
||||||
|
className?: string
|
||||||
|
}> = ({ children, position, top, op, className }) => {
|
||||||
|
const state = useCodeMirrorStateContext()
|
||||||
|
const view = useCodeMirrorViewContext()
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
|
const highlighted = isSelectionWithinOp(op, state.selection.main)
|
||||||
|
|
||||||
|
const focusHandler = useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
// without setTimeout, error "EditorView.update are not allowed while an update is in progress" can occur
|
||||||
|
// this can be avoided by using onClick rather than onFocus but it will then not pick up <Tab> or <Shift+Tab> events for focusing entries
|
||||||
|
view.dispatch({
|
||||||
|
selection: EditorSelection.cursor(position),
|
||||||
|
effects: EditorView.scrollIntoView(position, { y: 'center' }),
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
setFocused(true)
|
||||||
|
}, [view, position])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onFocus={focusHandler}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={position + 1}
|
||||||
|
className={classNames(
|
||||||
|
'review-panel-entry',
|
||||||
|
{
|
||||||
|
'review-panel-entry-focused': focused,
|
||||||
|
'review-panel-entry-highlighted': highlighted,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-top={top}
|
||||||
|
data-pos={position}
|
||||||
|
style={{
|
||||||
|
position: top === undefined ? 'relative' : 'absolute',
|
||||||
|
visibility: top === undefined ? 'visible' : 'hidden',
|
||||||
|
transition: 'top .3s, left .1s, right .1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,6 +2,9 @@ import { AnyOperation } from '../../../../../types/change'
|
||||||
import { SelectionRange } from '@codemirror/state'
|
import { SelectionRange } from '@codemirror/state'
|
||||||
import { visibleTextLength } from '@/utils/operations'
|
import { visibleTextLength } from '@/utils/operations'
|
||||||
|
|
||||||
export const isFocused = (op: AnyOperation, range: SelectionRange): boolean => {
|
export const isSelectionWithinOp = (
|
||||||
|
op: AnyOperation,
|
||||||
|
range: SelectionRange
|
||||||
|
): boolean => {
|
||||||
return range.to >= op.p && range.from <= op.p + visibleTextLength(op)
|
return range.to >= op.p && range.from <= op.p + visibleTextLength(op)
|
||||||
}
|
}
|
|
@ -1,13 +1,10 @@
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
|
|
||||||
export const positionItems = debounce(
|
const COLLAPSED_HEADER_HEIGHT = 75
|
||||||
(
|
const OFFSET_FOR_ENTRIES_ABOVE = 70
|
||||||
element: HTMLDivElement,
|
|
||||||
containerElement: HTMLDivElement,
|
|
||||||
previousFocusedItemIndex: number
|
|
||||||
) => {
|
|
||||||
const scrollRect = containerElement.getBoundingClientRect()
|
|
||||||
|
|
||||||
|
export const positionItems = debounce(
|
||||||
|
(element: HTMLDivElement, previousFocusedItemIndex: number) => {
|
||||||
const items = Array.from(
|
const items = Array.from(
|
||||||
element.querySelectorAll<HTMLDivElement>('.review-panel-entry')
|
element.querySelectorAll<HTMLDivElement>('.review-panel-entry')
|
||||||
)
|
)
|
||||||
|
@ -21,19 +18,25 @@ export const positionItems = debounce(
|
||||||
let focusedItemIndex = items.findIndex(item =>
|
let focusedItemIndex = items.findIndex(item =>
|
||||||
item.classList.contains('review-panel-entry-focused')
|
item.classList.contains('review-panel-entry-focused')
|
||||||
)
|
)
|
||||||
|
if (focusedItemIndex === -1) {
|
||||||
|
// if entry was not focused manually
|
||||||
|
// check if there is an entry in selection and use that as the focused item
|
||||||
|
focusedItemIndex = items.findIndex(item =>
|
||||||
|
item.classList.contains('review-panel-entry-highlighted')
|
||||||
|
)
|
||||||
|
}
|
||||||
if (focusedItemIndex === -1) {
|
if (focusedItemIndex === -1) {
|
||||||
focusedItemIndex = previousFocusedItemIndex
|
focusedItemIndex = previousFocusedItemIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: editorPadding?
|
|
||||||
const topDiff = scrollRect.top - 80
|
|
||||||
|
|
||||||
const focusedItem = items[focusedItemIndex]
|
const focusedItem = items[focusedItemIndex]
|
||||||
if (!focusedItem) {
|
if (!focusedItem) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const focusedItemTop = Number(focusedItem.dataset.top)
|
|
||||||
focusedItem.style.top = `${focusedItemTop + topDiff}px`
|
const focusedItemTop = getTopPosition(focusedItem, focusedItemIndex === 0)
|
||||||
|
|
||||||
|
focusedItem.style.top = `${focusedItemTop}px`
|
||||||
focusedItem.style.visibility = 'visible'
|
focusedItem.style.visibility = 'visible'
|
||||||
const focusedItemRect = focusedItem.getBoundingClientRect()
|
const focusedItemRect = focusedItem.getBoundingClientRect()
|
||||||
|
|
||||||
|
@ -42,12 +45,12 @@ export const positionItems = debounce(
|
||||||
for (let i = focusedItemIndex - 1; i >= 0; i--) {
|
for (let i = focusedItemIndex - 1; i >= 0; i--) {
|
||||||
const item = items[i]
|
const item = items[i]
|
||||||
const rect = item.getBoundingClientRect()
|
const rect = item.getBoundingClientRect()
|
||||||
let top = Number(item.dataset.top)
|
let top = getTopPosition(item, i === 0)
|
||||||
const bottom = top + rect.height
|
const bottom = top + rect.height
|
||||||
if (bottom > topLimit) {
|
if (bottom > topLimit) {
|
||||||
top = topLimit - rect.height - 10
|
top = topLimit - rect.height - 10
|
||||||
}
|
}
|
||||||
item.style.top = `${top + topDiff}px`
|
item.style.top = `${top}px`
|
||||||
item.style.visibility = 'visible'
|
item.style.visibility = 'visible'
|
||||||
topLimit = top
|
topLimit = top
|
||||||
}
|
}
|
||||||
|
@ -57,11 +60,11 @@ export const positionItems = debounce(
|
||||||
for (let i = focusedItemIndex + 1; i < items.length; i++) {
|
for (let i = focusedItemIndex + 1; i < items.length; i++) {
|
||||||
const item = items[i]
|
const item = items[i]
|
||||||
const rect = item.getBoundingClientRect()
|
const rect = item.getBoundingClientRect()
|
||||||
let top = Number(item.dataset.top)
|
let top = getTopPosition(item, false)
|
||||||
if (top < bottomLimit) {
|
if (top < bottomLimit) {
|
||||||
top = bottomLimit + 10
|
top = bottomLimit + 10
|
||||||
}
|
}
|
||||||
item.style.top = `${top + topDiff}px`
|
item.style.top = `${top}px`
|
||||||
item.style.visibility = 'visible'
|
item.style.visibility = 'visible'
|
||||||
bottomLimit = top + rect.height
|
bottomLimit = top + rect.height
|
||||||
}
|
}
|
||||||
|
@ -75,3 +78,8 @@ export const positionItems = debounce(
|
||||||
100,
|
100,
|
||||||
{ leading: false, trailing: true, maxWait: 1000 }
|
{ leading: false, trailing: true, maxWait: 1000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function getTopPosition(item: HTMLDivElement, isFirstEntry: boolean) {
|
||||||
|
const offset = isFirstEntry ? 0 : OFFSET_FOR_ENTRIES_ABOVE
|
||||||
|
return Math.max(COLLAPSED_HEADER_HEIGHT + offset, Number(item.dataset.top))
|
||||||
|
}
|
||||||
|
|
|
@ -33,9 +33,10 @@
|
||||||
gap: @spacing-04;
|
gap: @spacing-04;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-panel-entry-focused {
|
.review-panel-entry-focused,
|
||||||
|
.review-panel-entry-highlighted {
|
||||||
margin-left: @spacing-01;
|
margin-left: @spacing-01;
|
||||||
border: 2px solid @blue-50;
|
border: 1px solid @blue-50;
|
||||||
}
|
}
|
||||||
.review-panel-entry-header {
|
.review-panel-entry-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
Loading…
Reference in a new issue