diff --git a/components/work-review/WorkReviewReviewerWorkspace.tsx b/components/work-review/WorkReviewReviewerWorkspace.tsx index 606f677..fe0706f 100644 --- a/components/work-review/WorkReviewReviewerWorkspace.tsx +++ b/components/work-review/WorkReviewReviewerWorkspace.tsx @@ -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({ )} -
+
{activeGroup ? (
(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(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 && ( +
+
+ {([ + { index: 0 as const, label: '作品图' }, + { index: 1 as const, label: '参考图' }, + ]).map((item) => { + const active = activeMobilePane === item.index + return ( + + ) + })} +
+
+ )}
{ + 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) + } }} >
diff --git a/lib/__tests__/work-review-reviewer-workspace.test.tsx b/lib/__tests__/work-review-reviewer-workspace.test.tsx index 4615f9a..cf6ad69 100644 --- a/lib/__tests__/work-review-reviewer-workspace.test.tsx +++ b/lib/__tests__/work-review-reviewer-workspace.test.tsx @@ -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() + + 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() + expect(screen.queryByRole('button', { name: '作品图' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '参考图' })).not.toBeInTheDocument() + }) })