添加取消选中实体按钮

- 在左侧工具栏拖拽/选择下方新增“取消选中”按钮,提供等同 Esc 的可点击入口。

- 将 VideoWorkspace 的取消选中信号传入 CanvasArea,统一清空 mask 选区、临时绘制状态和顶点选择。

- 修正 Canvas 本地选区与全局 selectedMaskIds 的同步,避免取消后旧本地选区被重新发布。

- 补充 ToolsPalette、CanvasArea 回归测试,覆盖实体按钮位置、回调和 clearSelectionSignal 行为。

- 更新 README、AGENTS 与前端审计/需求冻结/设计冻结/测试计划/交互状态机文档。
This commit is contained in:
2026-05-04 04:09:32 +08:00
parent 141dd4ce4b
commit 87b82b882f
12 changed files with 120 additions and 24 deletions

View File

@@ -122,6 +122,35 @@ describe('CanvasArea', () => {
expect(useStore.getState().activeClassId).toBe('c1');
});
it('clears selected masks when the toolbar clear-selection signal changes', async () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z',
label: '胆囊',
color: '#ff0000',
classId: 'c1',
segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]],
},
],
});
const { rerender } = render(<CanvasArea activeTool="move" frame={frame} clearSelectionSignal={0} />);
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
rerender(<CanvasArea activeTool="move" frame={frame} clearSelectionSignal={1} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().activeClassId).toBe('c1');
});
it('refines one SAM2 candidate mask from an initial box with positive and negative points', async () => {
apiMock.predictMask
.mockResolvedValueOnce({

View File

@@ -22,6 +22,7 @@ interface CanvasAreaProps {
frame: Frame | null;
currentFrameNumber?: number;
totalFrames?: number;
clearSelectionSignal?: number;
onRequestDeleteMasks?: (maskIds: string[]) => void;
onRequestBooleanFrameRange?: (request: BooleanFrameRangeRequest) => void;
onBooleanOperationStart?: () => void;
@@ -517,6 +518,7 @@ export function CanvasArea({
frame,
currentFrameNumber,
totalFrames,
clearSelectionSignal,
onRequestDeleteMasks,
onRequestBooleanFrameRange,
onBooleanOperationStart,
@@ -549,6 +551,8 @@ export function CanvasArea({
const paintStrokeRef = useRef<CanvasPoint[]>([]);
const paintToolRef = useRef<string | null>(null);
const lastPaintPointRef = useRef<CanvasPoint | null>(null);
const lastClearSelectionSignalRef = useRef<number | undefined>(clearSelectionSignal);
const clearSelectionInProgressRef = useRef(false);
const masks = useStore((state) => state.masks);
const addMask = useStore((state) => state.addMask);
@@ -606,6 +610,20 @@ export function CanvasArea({
paintStrokeRef.current = nextPoints;
setPaintStrokePointsState(nextPoints);
}, []);
const clearSelectionState = useCallback(() => {
clearSelectionInProgressRef.current = true;
setPolygonPoints([]);
setManualStart(null);
setManualCurrent(null);
setPaintStrokePoints([]);
paintToolRef.current = null;
lastPaintPointRef.current = null;
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [setGlobalSelectedMaskIds, setPaintStrokePoints]);
const currentLayerLabel = selectedMask
? `${selectedMask.className || selectedMask.label}${selectedMask.annotationId ? ` #${selectedMask.annotationId}` : ' (未保存)'}`
: '未选择';
@@ -740,6 +758,13 @@ export function CanvasArea({
});
}, [frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
useEffect(() => {
if (clearSelectionSignal === undefined) return;
if (lastClearSelectionSignalRef.current === clearSelectionSignal) return;
lastClearSelectionSignalRef.current = clearSelectionSignal;
clearSelectionState();
}, [clearSelectionSignal, clearSelectionState]);
useEffect(() => {
setManualStart(null);
setManualCurrent(null);
@@ -788,6 +813,17 @@ export function CanvasArea({
const validLocalSelectedIds = selectedMaskIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
));
if (clearSelectionInProgressRef.current) {
if (selectedMaskIds.length === 0) {
clearSelectionInProgressRef.current = false;
return;
}
setSelectedMaskId(null);
setSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
return;
}
if (selectedMaskIds.length > 0 && validLocalSelectedIds.length === 0) {
return;
}
@@ -1380,17 +1416,7 @@ export function CanvasArea({
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setPolygonPoints([]);
setManualStart(null);
setManualCurrent(null);
setPaintStrokePoints([]);
paintToolRef.current = null;
lastPaintPointRef.current = null;
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
clearSelectionState();
return;
}
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) {
@@ -1422,7 +1448,7 @@ export function CanvasArea({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, setGlobalSelectedMaskIds, setPaintStrokePoints, updatePolygonMask]);
}, [clearSelectionState, deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
const boxRect = React.useMemo(() => {
if (!boxStart || !boxCurrent) return null;

View File

@@ -81,6 +81,21 @@ describe('ToolsPalette', () => {
expect(onClearMasks).toHaveBeenCalled();
});
it('exposes a physical clear selection button next to the selection tool', () => {
const onClearSelection = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearSelection={onClearSelection} />);
const moveButton = screen.getByTitle('拖拽 / 选择 (V)');
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
const editButton = screen.getByTitle('调整多边形 (E)');
fireEvent.click(clearSelectionButton);
expect(onClearSelection).toHaveBeenCalled();
expect(clearSelectionButton).toHaveAttribute('aria-label', '取消选中');
expect(moveButton.compareDocumentPosition(clearSelectionButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(clearSelectionButton.compareDocumentPosition(editButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('places colored auto propagation below the eraser tool', () => {
const setActiveTool = vi.fn();
const onAutoPropagate = vi.fn();
@@ -109,6 +124,7 @@ describe('ToolsPalette', () => {
const separators = Array.from(container.querySelectorAll('.h-px'));
const externalActionSeparator = screen.getByTestId('tool-group-separator');
const clearSelectionButton = screen.getByTitle('取消选中 (Esc)');
const circleButton = screen.getByTitle('创建圆 (O)');
const brushButton = screen.getByTitle('画笔 (B)');
const eraserButton = screen.getByTitle('橡皮擦 (X)');
@@ -121,6 +137,7 @@ describe('ToolsPalette', () => {
expect(separators).toHaveLength(3);
expect(externalActionSeparator).toBe(separators[2]);
expect(clearSelectionButton.compareDocumentPosition(circleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(circleButton.compareDocumentPosition(separators[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(separators[0].compareDocumentPosition(brushButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(eraserButton.compareDocumentPosition(autoButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { MousePointer2, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } from 'lucide-react';
import { MousePointer2, CircleOff, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { AiSegmentationIcon } from './AiSegmentationIcon';
import { useStore } from '../store/useStore';
@@ -10,6 +10,7 @@ interface ToolsPaletteProps {
onTriggerAI?: () => void;
onAutoPropagate?: () => void;
onImportGtMask?: () => void;
onClearSelection?: () => void;
onDeleteMasks?: () => void;
onClearMasks?: () => void;
canAutoPropagate?: boolean;
@@ -24,6 +25,7 @@ export function ToolsPalette({
onTriggerAI,
onAutoPropagate,
onImportGtMask,
onClearSelection,
onDeleteMasks,
onClearMasks,
canAutoPropagate = false,
@@ -86,6 +88,18 @@ export function ToolsPalette({
>
<Icon size={16} strokeWidth={isActive ? 2.5 : 2} />
</button>
{tool.id === 'move' && (
<button
type="button"
onClick={onClearSelection}
disabled={!onClearSelection}
title="取消选中 (Esc)"
aria-label="取消选中"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-gray-400 hover:bg-white/5 hover:text-white disabled:opacity-35 disabled:cursor-not-allowed"
>
<CircleOff size={16} strokeWidth={2.1} />
</button>
)}
{tool.id === 'eraser' && sizeControl && (
<div className="w-9 rounded-md border border-white/10 bg-white/[0.03] px-1 py-2 text-center">
<label htmlFor={`${activeTool}-size`} className="sr-only">{sizeControl.label}</label>

View File

@@ -503,6 +503,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const redoMasks = useStore((state) => state.redoMasks);
const [isSaving, setIsSaving] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [clearSelectionSignal, setClearSelectionSignal] = useState(0);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const [exportScope, setExportScope] = useState<ExportScope>('current');
const [exportOutputs, setExportOutputs] = useState<SegmentationExportOutput[]>([
@@ -924,6 +925,11 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
});
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
const handleClearSelection = useCallback(() => {
setSelectedMaskIds([]);
setClearSelectionSignal((value) => value + 1);
}, [setSelectedMaskIds]);
const handleDeleteSelectedMasks = useCallback(async (requestedMaskIds?: string[]) => {
if (!currentFrame) return;
const latestMasks = useStore.getState().masks;
@@ -2269,6 +2275,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
onTriggerAI={onNavigateToAI}
onAutoPropagate={() => void handleAutoPropagate()}
onImportGtMask={() => gtMaskInputRef.current?.click()}
onClearSelection={handleClearSelection}
onDeleteMasks={handleDeleteSelectedMasks}
onClearMasks={handleClearCurrentFrameMasks}
canAutoPropagate={Boolean(currentProject?.id && currentFrame?.id) && !isSaving && !isExporting && !isImportingGt}
@@ -2284,6 +2291,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
frame={currentFrame}
currentFrameNumber={currentFrameNumber || 0}
totalFrames={totalFrames}
clearSelectionSignal={clearSelectionSignal}
onRequestDeleteMasks={(maskIds) => void handleDeleteSelectedMasks(maskIds)}
onRequestBooleanFrameRange={handleBooleanFrameRangeRequest}
onBooleanOperationStart={clearPendingBooleanRangeSelection}