fix: stabilize mobile annotation box selection

This commit is contained in:
江林铖 2026-08-11 10:33:28 +08:00
parent d69eb6e252
commit 17d45352d3
2 changed files with 81 additions and 10 deletions

View File

@ -168,7 +168,12 @@ function LongImagePane({
)
const [internalActiveIndex, setInternalActiveIndex] = useState(0)
const [draftGeometry, setDraftGeometry] = useState<AnnotationGeometry | null>(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 && (
<div className="shrink-0 bg-[#fff7e6] px-3 py-2 text-center text-[11px] font-semibold text-[#7a4a00]" role="status">
{annotationRepositionMode
? '完成前可在作品图片上重新拖拽框选范围;滚动不会移动标注'
: `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽`}
? '完成前可重新拖拽框选范围;手机端如需滚动,请先完成当前标注'
: `已选择${drawingTool === 'rectangle' ? '矩形框选' : drawingTool === 'ellipse' ? '椭圆框选' : '箭头标注'},请在作品图片上按住并拖拽;手机端如需滚动,请先关闭标注工具`}
</div>
)}
<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)] ${placementMode ? 'cursor-crosshair touch-pan-y outline outline-2 outline-[#d49a31]' : ''}`}
className={`relative mx-auto max-w-[1200px] overflow-hidden bg-white shadow-[0_8px_28px_rgba(15,23,42,0.08)] ${placementMode ? 'cursor-crosshair touch-none select-none outline outline-2 outline-[#d49a31] [-webkit-touch-callout:none]' : ''}`}
role={annotationEnabled ? 'group' : undefined}
aria-label={annotationEnabled
? `${activeGroup.sourceName},${annotationRepositionMode ? '可调整当前标注位置,使用方向键微调' : '可添加位置批注'}`
@ -309,16 +314,30 @@ function LongImagePane({
tabIndex={placementMode ? 0 : undefined}
onPointerDown={(event) => {
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',

View File

@ -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(<WorkReviewReviewerWorkspace project={project} onSubmitReview={jest.fn()} />)
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()