(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;
diff --git a/src/components/ToolsPalette.test.tsx b/src/components/ToolsPalette.test.tsx
index dbe9ef6..bbfc611 100644
--- a/src/components/ToolsPalette.test.tsx
+++ b/src/components/ToolsPalette.test.tsx
@@ -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();
+
+ 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();
diff --git a/src/components/ToolsPalette.tsx b/src/components/ToolsPalette.tsx
index 0a31320..0a9d31d 100644
--- a/src/components/ToolsPalette.tsx
+++ b/src/components/ToolsPalette.tsx
@@ -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({
>
+ {tool.id === 'move' && (
+
+ )}
{tool.id === 'eraser' && sizeControl && (
diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx
index 5b4f174..895a581 100644
--- a/src/components/VideoWorkspace.tsx
+++ b/src/components/VideoWorkspace.tsx
@@ -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('current');
const [exportOutputs, setExportOutputs] = useState([
@@ -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}