fix: stabilize mobile annotation box selection
This commit is contained in:
parent
d69eb6e252
commit
17d45352d3
@ -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',
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user