diff --git a/components/work-review/WorkReviewReviewerWorkspace.tsx b/components/work-review/WorkReviewReviewerWorkspace.tsx index 849b742..6e9a787 100644 --- a/components/work-review/WorkReviewReviewerWorkspace.tsx +++ b/components/work-review/WorkReviewReviewerWorkspace.tsx @@ -168,7 +168,12 @@ function LongImagePane({ ) const [internalActiveIndex, setInternalActiveIndex] = useState(0) const [draftGeometry, setDraftGeometry] = useState(null) - const pointerStartRef = useRef<{ point: NormalizedPoint; clientX: number; clientY: number } | null>(null) + const pointerStartRef = useRef<{ + pointerId: number + point: NormalizedPoint + clientX: number + clientY: number + } | null>(null) const activeIndex = Math.min( controlledActiveIndex ?? internalActiveIndex, Math.max(0, displayGroups.length - 1), @@ -293,15 +298,15 @@ function LongImagePane({ {placementMode && (
{annotationRepositionMode - ? '完成前可在作品图片上重新拖拽框选范围;滚动不会移动标注' - : `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽`} + ? '完成前可重新拖拽框选范围;手机端如需滚动,请先完成当前标注' + : `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽;手机端如需滚动,请先关闭标注工具`}
)}
{activeGroup ? (
{ if (!placementMode || event.isPrimary === false || (event.button ?? 0) !== 0) return + event.preventDefault() const point = pointFromPointer(event.currentTarget, event.clientX, event.clientY) if (!point) return - pointerStartRef.current = { point, clientX: event.clientX, clientY: event.clientY } + pointerStartRef.current = { + pointerId: event.pointerId, + point, + clientX: event.clientX, + clientY: event.clientY, + } setDraftGeometry(createAnnotationGeometry(point, point, drawingTool, drawingColor)) if (typeof event.currentTarget.setPointerCapture === 'function') { - event.currentTarget.setPointerCapture(event.pointerId) + try { + event.currentTarget.setPointerCapture(event.pointerId) + } catch { + // 部分移动端浏览器在手势切换瞬间会拒绝捕获;touch-none 仍会保持框选手势。 + } } }} onPointerMove={(event) => { - if (!placementMode || event.isPrimary === false || !pointerStartRef.current) return + if (!placementMode + || event.isPrimary === false + || !pointerStartRef.current + || pointerStartRef.current.pointerId !== event.pointerId) return + event.preventDefault() const point = pointFromPointer(event.currentTarget, event.clientX, event.clientY) if (!point) return setDraftGeometry(createAnnotationGeometry( @@ -329,7 +348,11 @@ function LongImagePane({ )) }} onPointerUp={(event) => { - if (!placementMode || event.isPrimary === false || !pointerStartRef.current) return + if (!placementMode + || event.isPrimary === false + || !pointerStartRef.current + || pointerStartRef.current.pointerId !== event.pointerId) return + event.preventDefault() const distance = Math.hypot( event.clientX - pointerStartRef.current.clientX, event.clientY - pointerStartRef.current.clientY, @@ -339,7 +362,11 @@ function LongImagePane({ pointerStartRef.current = null if (typeof event.currentTarget.releasePointerCapture === 'function' && event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) + try { + event.currentTarget.releasePointerCapture(event.pointerId) + } catch { + // 指针已由系统释放时无需再次处理。 + } } if (!point || distance < 10) { setDraftGeometry(null) @@ -347,10 +374,19 @@ function LongImagePane({ } commitGeometry(createAnnotationGeometry(start, point, drawingTool, drawingColor)) }} - onPointerCancel={() => { + onPointerCancel={(event) => { + if (pointerStartRef.current?.pointerId !== event.pointerId) return pointerStartRef.current = null setDraftGeometry(null) }} + onLostPointerCapture={(event) => { + if (pointerStartRef.current?.pointerId !== event.pointerId) return + pointerStartRef.current = null + setDraftGeometry(null) + }} + onContextMenu={(event) => { + if (placementMode) event.preventDefault() + }} onKeyDown={(event) => { if (annotationRepositionMode && annotationPosition && [ 'ArrowLeft', diff --git a/lib/__tests__/work-review-reviewer-workspace.test.tsx b/lib/__tests__/work-review-reviewer-workspace.test.tsx index 725f849..6f87a4b 100644 --- a/lib/__tests__/work-review-reviewer-workspace.test.tsx +++ b/lib/__tests__/work-review-reviewer-workspace.test.tsx @@ -63,6 +63,41 @@ const projectWithReference: WorkReviewProject = { } describe('WorkReviewReviewerWorkspace', () => { + test('keeps mobile box selection under app control and recovers after a cancelled gesture', async () => { + window.PointerEvent = MouseEvent as typeof PointerEvent + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button', { name: '+ 添加标注' })[0]) + const canvas = screen.getAllByRole('group', { name: /作品长图,可添加位置批注/ })[0] + expect(canvas).toHaveClass('touch-none', 'select-none', '[-webkit-touch-callout:none]') + expect(canvas).not.toHaveClass('touch-pan-y') + expect(screen.getAllByRole('status')[0]).toHaveTextContent('手机端如需滚动,请先关闭标注工具') + + jest.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 1000, + bottom: 2000, + width: 1000, + height: 2000, + toJSON: () => ({}), + }) + + fireEvent.pointerDown(canvas, { pointerId: 1, button: 0, clientX: 200, clientY: 400 }) + fireEvent.pointerMove(canvas, { pointerId: 1, button: 0, clientX: 450, clientY: 800 }) + fireEvent.pointerCancel(canvas, { pointerId: 1 }) + expect(screen.queryByRole('textbox', { name: /修改意见/ })).not.toBeInTheDocument() + + fireEvent.pointerDown(canvas, { pointerId: 2, button: 0, clientX: 300, clientY: 500 }) + fireEvent.pointerMove(canvas, { pointerId: 2, button: 0, clientX: 600, clientY: 950 }) + fireEvent.pointerUp(canvas, { pointerId: 2, button: 0, clientX: 600, clientY: 950 }) + + expect(screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ })).toBeInTheDocument() + }) + test('moves an unfinished annotation, locks it on completion, and submits the moved position', async () => { window.PointerEvent = MouseEvent as typeof PointerEvent const user = userEvent.setup()