添加取消选中实体按钮
- 在左侧工具栏拖拽/选择下方新增“取消选中”按钮,提供等同 Esc 的可点击入口。 - 将 VideoWorkspace 的取消选中信号传入 CanvasArea,统一清空 mask 选区、临时绘制状态和顶点选择。 - 修正 Canvas 本地选区与全局 selectedMaskIds 的同步,避免取消后旧本地选区被重新发布。 - 补充 ToolsPalette、CanvasArea 回归测试,覆盖实体按钮位置、回调和 clearSelectionSignal 行为。 - 更新 README、AGENTS 与前端审计/需求冻结/设计冻结/测试计划/交互状态机文档。
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user