feat: reposition draft review annotations

This commit is contained in:
江林铖 2026-08-01 16:16:20 +08:00
parent 94641795cf
commit 04fd25b60e
3 changed files with 201 additions and 51 deletions

View File

@ -8,6 +8,7 @@ export type WorkReviewDisplayAnnotation = {
reviewerName?: string
reviewerRole?: WorkReviewRole
draft?: boolean
positionEditable?: boolean
}
function markerTone(item: WorkReviewDisplayAnnotation) {
@ -33,7 +34,8 @@ export function WorkReviewAnnotationMarker({
? '元泽'
: '评审人'
const summary = annotation.comment.trim() || '待填写修改意见'
const label = `${roleLabel}标注 ${item.markerNumber}${item.reviewerName ? `,${item.reviewerName}` : ''}:${summary}`
const positionState = item.positionEditable ? ',位置可调整' : ''
const label = `${roleLabel}标注 ${item.markerNumber}${positionState}${item.reviewerName ? `,${item.reviewerName}` : ''}:${summary}`
const visualTitle = showComment ? label : `${roleLabel}标注 ${item.markerNumber}`
return (
@ -44,12 +46,13 @@ export function WorkReviewAnnotationMarker({
onClick?.()
}}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
aria-label={label}
title={visualTitle}
className="group absolute z-20 h-11 w-11 -translate-x-1/2 -translate-y-1/2 outline-none"
className={`group absolute z-20 h-11 w-11 -translate-x-1/2 -translate-y-1/2 outline-none ${item.positionEditable ? 'cursor-move' : 'cursor-pointer'}`}
style={{ left: `${annotation.x * 100}%`, top: `${annotation.y * 100}%` }}
>
<span className={`absolute left-1/2 top-1/2 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.35)] ring-4 ${markerTone(item)} group-focus-visible:ring-[#8ab9ff]`}>
<span className={`absolute left-1/2 top-1/2 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.35)] ring-4 ${markerTone(item)} ${item.positionEditable ? 'outline outline-2 outline-offset-2 outline-[#8a5200]' : ''} group-focus-visible:ring-[#8ab9ff]`}>
{item.markerNumber}
</span>
<span

View File

@ -87,11 +87,14 @@ function LongImagePane({
annotations = [],
annotationEnabled = false,
annotationMode = false,
annotationRepositionMode = false,
annotationPosition,
annotationCount,
showAnnotationComments = true,
onToggleAnnotationMode,
onToggleAnnotationComments,
onAddAnnotation,
onMoveAnnotation,
onSelectDraftAnnotation,
}: {
label: string
@ -103,11 +106,14 @@ function LongImagePane({
annotations?: WorkReviewDisplayAnnotation[]
annotationEnabled?: boolean
annotationMode?: boolean
annotationRepositionMode?: boolean
annotationPosition?: Pick<WorkReviewImageAnnotation, 'x' | 'y'>
annotationCount?: number
showAnnotationComments?: boolean
onToggleAnnotationMode?: () => void
onToggleAnnotationComments?: () => void
onAddAnnotation?: (x: number, y: number) => void
onMoveAnnotation?: (x: number, y: number) => void
onSelectDraftAnnotation?: (annotationId: string) => void
}) {
const displayGroups = useMemo(
@ -121,6 +127,7 @@ function LongImagePane({
Math.max(0, displayGroups.length - 1),
)
const activeGroup = displayGroups[activeIndex]
const placementMode = annotationMode || annotationRepositionMode
useEffect(() => {
if (controlledActiveIndex == null) setInternalActiveIndex(0)
@ -136,7 +143,11 @@ function LongImagePane({
if (bounds.width <= 0 || bounds.height <= 0) return
const x = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width))
const y = Math.min(1, Math.max(0, (clientY - bounds.top) / bounds.height))
onAddAnnotation?.(x, y)
if (annotationRepositionMode) {
onMoveAnnotation?.(x, y)
} else {
onAddAnnotation?.(x, y)
}
}
return (
@ -148,8 +159,9 @@ function LongImagePane({
<button
type="button"
onClick={onToggleAnnotationMode}
disabled={annotationRepositionMode}
aria-pressed={annotationMode}
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors ${
className={`min-h-8 shrink-0 rounded px-2.5 text-[11px] font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
annotationMode
? 'bg-[#8a5200] text-white hover:bg-[#6d4100]'
: 'border border-[#d3dae5] bg-white text-[#344054] hover:border-[#8ab9ff]'
@ -201,25 +213,29 @@ function LongImagePane({
)}
</div>
{annotationMode && (
{placementMode && (
<div className="shrink-0 bg-[#fff7e6] px-3 py-2 text-center text-[11px] font-semibold text-[#7a4a00]" role="status">
点击作品中的修改位置添加批注;滚动不会产生标记
{annotationRepositionMode
? '完成前可点击作品图片重新调整当前标注位置;滚动不会移动标注'
: '点击作品中的修改位置添加批注;滚动不会产生标记'}
</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)] ${annotationMode ? '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-pan-y outline outline-2 outline-[#d49a31]' : ''}`}
role={annotationEnabled ? 'group' : undefined}
aria-label={annotationEnabled ? `${activeGroup.sourceName},可添加位置批注` : undefined}
tabIndex={annotationMode ? 0 : undefined}
aria-label={annotationEnabled
? `${activeGroup.sourceName},${annotationRepositionMode ? '可调整当前标注位置,使用方向键微调' : '可添加位置批注'}`
: undefined}
tabIndex={placementMode ? 0 : undefined}
onPointerDown={(event) => {
if (!annotationMode || (event.button ?? 0) !== 0) return
if (!placementMode || event.isPrimary === false || (event.button ?? 0) !== 0) return
pointerStartRef.current = { x: event.clientX, y: event.clientY }
}}
onPointerUp={(event) => {
if (!annotationMode || !pointerStartRef.current) return
if (!placementMode || event.isPrimary === false || !pointerStartRef.current) return
const distance = Math.hypot(
event.clientX - pointerStartRef.current.x,
event.clientY - pointerStartRef.current.y,
@ -231,7 +247,29 @@ function LongImagePane({
pointerStartRef.current = null
}}
onKeyDown={(event) => {
if (annotationMode && (event.key === 'Enter' || event.key === ' ')) {
if (annotationRepositionMode && annotationPosition && [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
].includes(event.key)) {
event.preventDefault()
const step = event.shiftKey ? 0.05 : 0.01
const nextX = event.key === 'ArrowLeft'
? annotationPosition.x - step
: event.key === 'ArrowRight'
? annotationPosition.x + step
: annotationPosition.x
const nextY = event.key === 'ArrowUp'
? annotationPosition.y - step
: event.key === 'ArrowDown'
? annotationPosition.y + step
: annotationPosition.y
onMoveAnnotation?.(
Math.min(1, Math.max(0, nextX)),
Math.min(1, Math.max(0, nextY)),
)
} else if (annotationMode && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
placeAnnotation(event.currentTarget, bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)
@ -302,6 +340,7 @@ export function WorkReviewReviewerWorkspace({
Array.from({ length: getImageCount(latestVersion) }, () => [])
))
const [selectedAnnotation, setSelectedAnnotation] = useState<{ imageIndex: number; id: string } | null>(null)
const [positionEditableAnnotation, setPositionEditableAnnotation] = useState<{ imageIndex: number; id: string } | null>(null)
const [annotationMode, setAnnotationMode] = useState(false)
const [showAnnotationComments, setShowAnnotationComments] = useState(true)
const [activeMobilePane, setActiveMobilePane] = useState<0 | 1>(0)
@ -330,6 +369,7 @@ export function WorkReviewReviewerWorkspace({
setActiveWorkImageIndex(0)
setDraftAnnotations(Array.from({ length: workImageCount }, () => []))
setSelectedAnnotation(null)
setPositionEditableAnnotation(null)
setAnnotationMode(false)
setShowAnnotationComments(true)
setActiveMobilePane(0)
@ -370,6 +410,9 @@ export function WorkReviewReviewerWorkspace({
const selectedDraft = selectedAnnotation
? draftAnnotations[selectedAnnotation.imageIndex]?.find((item) => item.id === selectedAnnotation.id)
: undefined
const positionEditableDraft = positionEditableAnnotation
? draftAnnotations[positionEditableAnnotation.imageIndex]?.find((item) => item.id === positionEditableAnnotation.id)
: undefined
const totalDraftCount = draftAnnotations.reduce((sum, annotations) => sum + annotations.length, 0)
const totalSubmittedAnnotationCount = selectedVersion.reviews.reduce((reviewSum, review) => (
reviewSum + (review.imageComments || []).reduce((imageSum, imageComment) => (
@ -388,7 +431,13 @@ export function WorkReviewReviewerWorkspace({
})
const visibleAnnotations: WorkReviewDisplayAnnotation[] = [
...submittedAnnotations,
...currentDrafts.map((annotation, index) => ({ annotation, markerNumber: index + 1, draft: true })),
...currentDrafts.map((annotation, index) => ({
annotation,
markerNumber: index + 1,
draft: true,
positionEditable: positionEditableAnnotation?.imageIndex === activeWorkImageIndex
&& positionEditableAnnotation.id === annotation.id,
})),
]
const updateSplitFromPointer = (clientX: number) => {
@ -404,7 +453,7 @@ export function WorkReviewReviewerWorkspace({
setActiveMobilePane(index)
if (index === 1) {
setAnnotationMode(false)
setSelectedAnnotation(null)
if (!positionEditableAnnotation) setSelectedAnnotation(null)
}
scrollPaneTo(scroller, scroller.clientWidth * index, 'smooth')
}
@ -420,9 +469,24 @@ export function WorkReviewReviewerWorkspace({
index === activeWorkImageIndex ? [...items, annotation] : items
)))
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id: annotation.id })
setPositionEditableAnnotation({ imageIndex: activeWorkImageIndex, id: annotation.id })
setAnnotationMode(false)
setError(null)
setLiveMessage(`已在图 ${activeWorkImageIndex + 1} 添加标注点`)
setLiveMessage(`已在图 ${activeWorkImageIndex + 1} 添加标注点,完成前可点击图片调整位置`)
}
const movePositionEditableAnnotation = (x: number, y: number) => {
if (!positionEditableAnnotation || !positionEditableDraft) return
setDraftAnnotations((current) => current.map((items, imageIndex) => (
imageIndex === positionEditableAnnotation.imageIndex
? items.map((item) => item.id === positionEditableAnnotation.id ? { ...item, x, y } : item)
: items
)))
setSelectedAnnotation(positionEditableAnnotation)
setError(null)
const markerIndex = (draftAnnotations[positionEditableAnnotation.imageIndex] || [])
.findIndex((item) => item.id === positionEditableAnnotation.id)
setLiveMessage(`标注 ${markerIndex + 1} 已移动到新位置`)
}
const updateSelectedAnnotation = (comment: string) => {
@ -443,6 +507,10 @@ export function WorkReviewReviewerWorkspace({
: items
)))
setSelectedAnnotation(null)
if (positionEditableAnnotation?.imageIndex === selectedAnnotation.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation.id) {
setPositionEditableAnnotation(null)
}
setError(null)
setLiveMessage('批注已删除')
}
@ -452,9 +520,12 @@ export function WorkReviewReviewerWorkspace({
setError('请填写这个标注点的修改意见')
return
}
const lockedPosition = positionEditableAnnotation?.imageIndex === selectedAnnotation?.imageIndex
&& positionEditableAnnotation?.id === selectedAnnotation?.id
setSelectedAnnotation(null)
if (lockedPosition) setPositionEditableAnnotation(null)
setError(null)
setLiveMessage('批注已保存到本次评审草稿')
setLiveMessage(lockedPosition ? '标注位置已锁定' : '批注修改意见已保存')
}
const findIncompleteAnnotation = () => {
@ -465,8 +536,53 @@ export function WorkReviewReviewerWorkspace({
return null
}
const changeActiveWorkImage = (index: number) => {
if (positionEditableAnnotation) {
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
if (index !== activeWorkImageIndex) {
setError('请先点击“完成”锁定当前标注位置,再切换图片')
}
return
}
setActiveWorkImageIndex(index)
setSelectedAnnotation(null)
setAnnotationMode(false)
}
const toggleAnnotationMode = () => {
if (positionEditableAnnotation) {
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
setError('请先完成或删除当前标注')
return
}
setAnnotationMode((current) => !current)
setSelectedAnnotation(null)
setError(null)
}
const selectDraftAnnotation = (id: string) => {
if (positionEditableAnnotation && positionEditableAnnotation.id !== id) {
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
setError('请先完成或删除当前标注')
return
}
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id })
setAnnotationMode(false)
setError(null)
}
const submitDecision = async (decision: 'approved' | 'revision_required') => {
if (!pendingRole || workImageCount === 0) return
if (positionEditableAnnotation) {
setActiveWorkImageIndex(positionEditableAnnotation.imageIndex)
setSelectedAnnotation(positionEditableAnnotation)
setAnnotationMode(false)
setError('请先点击“完成”锁定当前标注位置')
return
}
const incomplete = findIncompleteAnnotation()
if (incomplete) {
setActiveWorkImageIndex(incomplete.imageIndex)
@ -501,6 +617,7 @@ export function WorkReviewReviewerWorkspace({
})
setDraftAnnotations(Array.from({ length: workImageCount }, () => []))
setSelectedAnnotation(null)
setPositionEditableAnnotation(null)
setAnnotationMode(false)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '评审提交失败')
@ -597,28 +714,19 @@ export function WorkReviewReviewerWorkspace({
imageUrls={selectedVersion.imageUrls || []}
emptyMessage="当前版本没有作品图片"
activeIndex={activeWorkImageIndex}
onActiveIndexChange={(index) => {
setActiveWorkImageIndex(index)
setSelectedAnnotation(null)
setAnnotationMode(false)
}}
onActiveIndexChange={changeActiveWorkImage}
annotations={visibleAnnotations}
annotationEnabled={Boolean(pendingRole)}
annotationMode={annotationMode}
annotationRepositionMode={Boolean(positionEditableDraft)}
annotationPosition={positionEditableDraft}
annotationCount={totalAnnotationCount}
showAnnotationComments={showAnnotationComments}
onToggleAnnotationMode={() => {
setAnnotationMode((current) => !current)
setSelectedAnnotation(null)
setError(null)
}}
onToggleAnnotationMode={toggleAnnotationMode}
onToggleAnnotationComments={() => setShowAnnotationComments((current) => !current)}
onAddAnnotation={addAnnotation}
onSelectDraftAnnotation={(id) => {
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id })
setAnnotationMode(false)
setError(null)
}}
onMoveAnnotation={movePositionEditableAnnotation}
onSelectDraftAnnotation={selectDraftAnnotation}
/>
</div>
{hasReferenceImages && (
@ -718,28 +826,19 @@ export function WorkReviewReviewerWorkspace({
imageUrls={selectedVersion.imageUrls || []}
emptyMessage="当前版本没有作品图片"
activeIndex={activeWorkImageIndex}
onActiveIndexChange={(index) => {
setActiveWorkImageIndex(index)
setSelectedAnnotation(null)
setAnnotationMode(false)
}}
onActiveIndexChange={changeActiveWorkImage}
annotations={visibleAnnotations}
annotationEnabled={Boolean(pendingRole)}
annotationMode={annotationMode}
annotationRepositionMode={Boolean(positionEditableDraft)}
annotationPosition={positionEditableDraft}
annotationCount={totalAnnotationCount}
showAnnotationComments={showAnnotationComments}
onToggleAnnotationMode={() => {
setAnnotationMode((current) => !current)
setSelectedAnnotation(null)
setError(null)
}}
onToggleAnnotationMode={toggleAnnotationMode}
onToggleAnnotationComments={() => setShowAnnotationComments((current) => !current)}
onAddAnnotation={addAnnotation}
onSelectDraftAnnotation={(id) => {
setSelectedAnnotation({ imageIndex: activeWorkImageIndex, id })
setAnnotationMode(false)
setError(null)
}}
onMoveAnnotation={movePositionEditableAnnotation}
onSelectDraftAnnotation={selectDraftAnnotation}
/>
</div>
{hasReferenceImages && (
@ -765,6 +864,10 @@ export function WorkReviewReviewerWorkspace({
<label className="grid min-w-0 gap-1.5">
<span className="text-[12px] font-bold text-[#344054]">
图 {selectedAnnotation!.imageIndex + 1} · 标注 {(draftAnnotations[selectedAnnotation!.imageIndex] || []).findIndex((item) => item.id === selectedAnnotation!.id) + 1} 修改意见
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id
? '(位置可调整)'
: ''}
</span>
<textarea
ref={annotationInputRef}
@ -775,6 +878,12 @@ export function WorkReviewReviewerWorkspace({
placeholder="说明这个位置需要如何调整"
className="min-h-16 resize-y rounded border border-[#cfd6e1] px-3 py-2 text-[13px] leading-5 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id ? (
<span className="text-[11px] font-semibold text-[#7a4a00]">
完成前可点击作品图片重新调整标注位置
</span>
) : null}
</label>
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
@ -791,7 +900,10 @@ export function WorkReviewReviewerWorkspace({
disabled={Boolean(busyAction)}
className="min-h-10 rounded bg-[#101828] px-4 text-[12px] font-bold text-white hover:bg-[#26354a] disabled:opacity-45"
>
完成
{positionEditableAnnotation?.imageIndex === selectedAnnotation!.imageIndex
&& positionEditableAnnotation.id === selectedAnnotation!.id
? '完成'
: '保存修改'}
</button>
</div>
</div>

View File

@ -63,7 +63,7 @@ const projectWithReference: WorkReviewProject = {
}
describe('WorkReviewReviewerWorkspace', () => {
test('adds a normalized point annotation and submits the current review for revision', async () => {
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()
const onSubmitReview = jest.fn().mockResolvedValue(undefined)
@ -92,8 +92,14 @@ describe('WorkReviewReviewerWorkspace', () => {
const comment = screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ })
await user.type(comment, '人物位置向左移动')
fireEvent.pointerDown(canvas, { button: 0, clientX: 750, clientY: 1200 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 750, clientY: 1200 })
await user.click(screen.getByRole('button', { name: '完成' }))
fireEvent.pointerDown(canvas, { button: 0, clientX: 100, clientY: 200 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 100, clientY: 200 })
expect(screen.getAllByText('已标注 1 个修改点')).toHaveLength(2)
const commentLabels = screen.getAllByText('人物位置向左移动')
expect(commentLabels).toHaveLength(2)
@ -112,11 +118,40 @@ describe('WorkReviewReviewerWorkspace', () => {
imageComments: [{
imageIndex: 0,
decision: 'revision_required',
annotations: [{ x: 0.25, y: 0.25, comment: '人物位置向左移动' }],
annotations: [{ x: 0.75, y: 0.6, comment: '人物位置向左移动' }],
}],
})
})
test('requires the reviewer to complete the current annotation before submitting', async () => {
window.PointerEvent = MouseEvent as typeof PointerEvent
const user = userEvent.setup()
const onSubmitReview = jest.fn().mockResolvedValue(undefined)
render(<WorkReviewReviewerWorkspace project={project} onSubmitReview={onSubmitReview} />)
await user.click(screen.getAllByRole('button', { name: '+ 添加标注' })[0])
const canvas = screen.getAllByRole('group', { name: /作品长图,可添加位置批注/ })[0]
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, { button: 0, clientX: 250, clientY: 500 })
fireEvent.pointerUp(canvas, { button: 0, clientX: 250, clientY: 500 })
await user.type(screen.getByRole('textbox', { name: /图 1 · 标注 1 修改意见/ }), '需要调整')
await user.click(screen.getByRole('button', { name: '提交给修改人' }))
expect(onSubmitReview).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('请先点击“完成”锁定当前标注位置')
})
test('approves directly without requiring a modification annotation', async () => {
const user = userEvent.setup()
const onSubmitReview = jest.fn().mockResolvedValue(undefined)
@ -151,7 +186,7 @@ describe('WorkReviewReviewerWorkspace', () => {
expect(referenceButton).toHaveAttribute('aria-pressed', 'false')
await user.click(referenceButton)
expect(scrollTo).toHaveBeenLastCalledWith({ left: 360, behavior: 'smooth' })
expect(scrollTo).toHaveBeenCalledWith({ left: 360, behavior: 'smooth' })
expect(referenceButton).toHaveAttribute('aria-pressed', 'true')
Object.defineProperty(scroller, 'scrollLeft', { configurable: true, value: 0 })