fix: restore mobile review comparison navigation

This commit is contained in:
江林铖 2026-08-01 15:31:56 +08:00
parent 3ceab7ac0b
commit 94641795cf
2 changed files with 138 additions and 7 deletions

View File

@ -68,6 +68,15 @@ function createAnnotationId() {
return `point-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function scrollPaneTo(element: HTMLDivElement | null, left: number, behavior: ScrollBehavior) {
if (!element) return
if (typeof element.scrollTo === 'function') {
element.scrollTo({ left, behavior })
return
}
element.scrollLeft = left
}
function LongImagePane({
label,
groups,
@ -198,7 +207,7 @@ function LongImagePane({
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-[#eef1f5] p-2 sm:p-3">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain bg-[#eef1f5] p-2 sm:p-3">
{activeGroup ? (
<div
className={`relative mx-auto max-w-[1200px] overflow-hidden bg-white shadow-[0_8px_28px_rgba(15,23,42,0.08)] ${annotationMode ? 'cursor-crosshair touch-pan-y outline outline-2 outline-[#d49a31]' : ''}`}
@ -295,6 +304,7 @@ export function WorkReviewReviewerWorkspace({
const [selectedAnnotation, setSelectedAnnotation] = useState<{ imageIndex: number; id: string } | null>(null)
const [annotationMode, setAnnotationMode] = useState(false)
const [showAnnotationComments, setShowAnnotationComments] = useState(true)
const [activeMobilePane, setActiveMobilePane] = useState<0 | 1>(0)
const [busyAction, setBusyAction] = useState<'approved' | 'revision_required' | null>(null)
const [error, setError] = useState<string | null>(null)
const [liveMessage, setLiveMessage] = useState('')
@ -307,6 +317,8 @@ export function WorkReviewReviewerWorkspace({
[latestVersion, project.versions, selectedVersionId],
)
const workImageCount = getImageCount(selectedVersion)
const hasReferenceImages = (selectedVersion?.referenceImageGroups?.length || 0) > 0
|| (selectedVersion?.referenceImageUrls?.length || 0) > 0
useEffect(() => {
if (!project.versions.some((version) => version.id === selectedVersionId)) {
@ -320,9 +332,30 @@ export function WorkReviewReviewerWorkspace({
setSelectedAnnotation(null)
setAnnotationMode(false)
setShowAnnotationComments(true)
setActiveMobilePane(0)
setError(null)
const frame = window.requestAnimationFrame(() => {
scrollPaneTo(mobileScrollerRef.current, 0, 'auto')
})
return () => window.cancelAnimationFrame(frame)
}, [selectedVersion?.id, workImageCount])
useEffect(() => {
if (hasReferenceImages) return
setActiveMobilePane(0)
scrollPaneTo(mobileScrollerRef.current, 0, 'auto')
}, [hasReferenceImages])
useEffect(() => {
const alignActivePane = () => {
const scroller = mobileScrollerRef.current
if (!scroller) return
scrollPaneTo(scroller, scroller.clientWidth * activeMobilePane, 'auto')
}
window.addEventListener('resize', alignActivePane)
return () => window.removeEventListener('resize', alignActivePane)
}, [activeMobilePane])
useEffect(() => {
if (!selectedAnnotation) return
const frame = window.requestAnimationFrame(() => annotationInputRef.current?.focus())
@ -333,8 +366,6 @@ export function WorkReviewReviewerWorkspace({
const isLatestSelected = selectedVersion.id === latestVersion.id
const pendingRole = isLatestSelected ? project.permissions.pendingReviewRoles[0] : undefined
const hasReferenceImages = (selectedVersion.referenceImageGroups?.length || 0) > 0
|| (selectedVersion.referenceImageUrls?.length || 0) > 0
const currentDrafts = draftAnnotations[activeWorkImageIndex] || []
const selectedDraft = selectedAnnotation
? draftAnnotations[selectedAnnotation.imageIndex]?.find((item) => item.id === selectedAnnotation.id)
@ -367,10 +398,15 @@ export function WorkReviewReviewerWorkspace({
setWorkPanePercent(Math.min(MAX_PANE_PERCENT, Math.max(MIN_PANE_PERCENT, Math.round(percent))))
}
const moveMobilePane = (index: number) => {
const moveMobilePane = (index: 0 | 1) => {
const scroller = mobileScrollerRef.current
if (!scroller) return
scroller.scrollTo({ left: scroller.clientWidth * index, behavior: 'smooth' })
setActiveMobilePane(index)
if (index === 1) {
setAnnotationMode(false)
setSelectedAnnotation(null)
}
scrollPaneTo(scroller, scroller.clientWidth * index, 'smooth')
}
const addAnnotation = (x: number, y: number) => {
@ -516,6 +552,37 @@ export function WorkReviewReviewerWorkspace({
aria-labelledby={`work-review-version-tab-${selectedVersion.id}`}
className="flex h-[calc(100dvh-144px)] min-h-[600px] flex-col overflow-hidden border border-[#dfe5ee] bg-white"
>
{hasReferenceImages && (
<div className="shrink-0 border-b border-[#dfe5ee] bg-white p-2 md:hidden">
<div
className="grid grid-cols-2 gap-1 rounded bg-[#eef2f6] p-1"
role="group"
aria-label="切换作品图与参考图"
>
{([
{ index: 0 as const, label: '作品图' },
{ index: 1 as const, label: '参考图' },
]).map((item) => {
const active = activeMobilePane === item.index
return (
<button
key={item.label}
type="button"
onClick={() => moveMobilePane(item.index)}
aria-pressed={active}
className={`min-h-11 rounded px-4 text-[13px] font-bold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-1 ${
active
? 'bg-[#dcecff] text-[#0050cb]'
: 'bg-transparent text-[#526173] hover:bg-white'
}`}
>
{item.label}
</button>
)
})}
</div>
</div>
)}
<div className="relative min-h-0 flex-1 overflow-hidden">
<div
ref={comparisonRef}
@ -619,10 +686,28 @@ export function WorkReviewReviewerWorkspace({
className="flex h-full snap-x snap-mandatory overflow-x-auto overscroll-x-contain md:hidden"
aria-label={hasReferenceImages ? '作品与参考图片对照区,左右滑动切换' : '作品图片'}
tabIndex={hasReferenceImages ? 0 : undefined}
onScroll={(event) => {
if (!hasReferenceImages || event.currentTarget.clientWidth <= 0) return
const nextPane = Math.min(
1,
Math.max(0, Math.round(event.currentTarget.scrollLeft / event.currentTarget.clientWidth)),
) as 0 | 1
setActiveMobilePane((current) => current === nextPane ? current : nextPane)
if (nextPane === 1 && annotationMode) {
setAnnotationMode(false)
setSelectedAnnotation(null)
}
}}
onKeyDown={(event) => {
if (!hasReferenceImages) return
if (event.key === 'ArrowLeft') moveMobilePane(0)
if (event.key === 'ArrowRight') moveMobilePane(1)
if (event.key === 'ArrowLeft') {
event.preventDefault()
moveMobilePane(0)
}
if (event.key === 'ArrowRight') {
event.preventDefault()
moveMobilePane(1)
}
}}
>
<div className="h-full w-full shrink-0 snap-start">

View File

@ -45,6 +45,23 @@ const project: WorkReviewProject = {
},
}
const projectWithReference: WorkReviewProject = {
...project,
versions: [{
...project.versions[0],
referenceImageGroups: [{
imageId: 'reference-1',
sourceName: '参考长图',
previewUrl: '/reference-preview.webp',
previewWidth: 1000,
previewHeight: 2000,
width: 1000,
height: 2000,
slices: [{ url: '/reference-slice.webp', width: 1000, height: 2000 }],
}],
}],
}
describe('WorkReviewReviewerWorkspace', () => {
test('adds a normalized point annotation and submits the current review for revision', async () => {
window.PointerEvent = MouseEvent as typeof PointerEvent
@ -118,4 +135,33 @@ describe('WorkReviewReviewerWorkspace', () => {
}],
})
})
test('keeps mobile work and reference controls synchronized with the comparison scroller', async () => {
const user = userEvent.setup()
render(<WorkReviewReviewerWorkspace project={projectWithReference} onSubmitReview={jest.fn()} />)
const scroller = screen.getByLabelText('作品与参考图片对照区,左右滑动切换')
const scrollTo = jest.fn()
Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: 360 })
Object.defineProperty(scroller, 'scrollTo', { configurable: true, value: scrollTo })
const workButton = screen.getByRole('button', { name: '作品图' })
const referenceButton = screen.getByRole('button', { name: '参考图' })
expect(workButton).toHaveAttribute('aria-pressed', 'true')
expect(referenceButton).toHaveAttribute('aria-pressed', 'false')
await user.click(referenceButton)
expect(scrollTo).toHaveBeenLastCalledWith({ left: 360, behavior: 'smooth' })
expect(referenceButton).toHaveAttribute('aria-pressed', 'true')
Object.defineProperty(scroller, 'scrollLeft', { configurable: true, value: 0 })
fireEvent.scroll(scroller)
expect(workButton).toHaveAttribute('aria-pressed', 'true')
})
test('does not show comparison controls when the version has no reference image', () => {
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={jest.fn()} />)
expect(screen.queryByRole('button', { name: '作品图' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '参考图' })).not.toBeInTheDocument()
})
})