修复绘制工具选区和分类应用逻辑
- 切换到创建多边形、创建矩形或创建圆时同步清空本地和全局 mask 选区,避免右侧语义分类树误改旧 mask。 - 手工创建多边形、矩形、圆后自动选中新创建的 mask,方便继续改类、编辑或保存。 - Esc 统一清空当前 mask 选区和临时绘制状态,让画笔可以在无选区状态下新建独立 mask。 - 画笔合并、新建和橡皮擦扣除后同步更新全局选区,保持右侧面板状态一致。 - 补充 CanvasArea 和 OntologyInspector 回归测试,覆盖创建工具清选区、新建自动选中、Esc 后画笔新建、无选区分类不改已有 mask。 - 更新前端审计、需求冻结、测试计划和 AGENTS 文档,记录无选区语义分类与绘制选区规则。
This commit is contained in:
@@ -1562,6 +1562,7 @@ describe('CanvasArea', () => {
|
||||
segmentation: [[120, 80, 260, 80, 260, 200, 120, 200]],
|
||||
bbox: [120, 80, 140, 120],
|
||||
}));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
|
||||
|
||||
useStore.getState().undoMasks();
|
||||
expect(useStore.getState().masks).toEqual([]);
|
||||
@@ -1592,6 +1593,34 @@ describe('CanvasArea', () => {
|
||||
}),
|
||||
}));
|
||||
expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64);
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
|
||||
});
|
||||
|
||||
it('clears the selected mask when switching to manual creation tools', async () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z',
|
||||
label: 'Existing',
|
||||
color: '#06b6d4',
|
||||
segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<CanvasArea activeTool="create_polygon" frame={frame} />);
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
|
||||
|
||||
act(() => useStore.getState().setSelectedMaskIds(['m1']));
|
||||
rerender(<CanvasArea activeTool="create_rectangle" frame={frame} />);
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
|
||||
|
||||
act(() => useStore.getState().setSelectedMaskIds(['m1']));
|
||||
rerender(<CanvasArea activeTool="create_circle" frame={frame} />);
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
|
||||
});
|
||||
|
||||
it('creates a brush mask when a semantic class is selected', () => {
|
||||
@@ -1777,6 +1806,7 @@ describe('CanvasArea', () => {
|
||||
source: 'manual',
|
||||
shape: '多边形',
|
||||
}));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
|
||||
});
|
||||
|
||||
it('closes a clicked polygon by clicking the first node again', () => {
|
||||
@@ -1803,6 +1833,51 @@ describe('CanvasArea', () => {
|
||||
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears selected masks with Escape so brush can create a new unmerged mask', async () => {
|
||||
useStore.setState({
|
||||
activeTemplateId: '2',
|
||||
activeClass: { id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 30, maskId: 2 },
|
||||
activeClassId: 'c2',
|
||||
selectedMaskIds: ['m1'],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 100 70 L 150 70 L 150 120 L 100 120 Z',
|
||||
label: '旧标签',
|
||||
color: '#ff0000',
|
||||
classId: 'c1',
|
||||
segmentation: [[100, 70, 150, 70, 150, 120, 100, 120]],
|
||||
area: 2500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<CanvasArea activeTool="move" frame={frame} />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
|
||||
|
||||
rerender(<CanvasArea activeTool="brush" frame={frame} />);
|
||||
const stage = screen.getByTestId('konva-stage');
|
||||
fireEvent.mouseDown(stage, { clientX: 300, clientY: 220 });
|
||||
fireEvent.mouseMove(stage, { clientX: 330, clientY: 240 });
|
||||
fireEvent.mouseUp(stage, { clientX: 360, clientY: 260 });
|
||||
|
||||
expect(useStore.getState().masks).toHaveLength(2);
|
||||
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
|
||||
id: 'm1',
|
||||
label: '旧标签',
|
||||
color: '#ff0000',
|
||||
}));
|
||||
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
|
||||
label: '肝脏',
|
||||
color: '#00ff00',
|
||||
classId: 'c2',
|
||||
classMaskId: 2,
|
||||
}));
|
||||
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[1].id]);
|
||||
});
|
||||
|
||||
it('shows contextual guidance for boolean selection ordering', () => {
|
||||
useStore.setState({
|
||||
masks: [
|
||||
|
||||
@@ -602,6 +602,7 @@ export function CanvasArea({
|
||||
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
|
||||
const isPaintTool = PAINT_TOOLS.has(effectiveTool);
|
||||
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
|
||||
const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool;
|
||||
const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool));
|
||||
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
|
||||
const activePaintRadius = Math.max(2, activePaintSize / 2);
|
||||
@@ -751,13 +752,14 @@ export function CanvasArea({
|
||||
lastPaintPointRef.current = null;
|
||||
setPolygonPoints([]);
|
||||
setSelectedVertexIndex(null);
|
||||
if (!isPolygonEditTool && !isBooleanTool && !isPaintTool) {
|
||||
if (!canKeepMaskSelection) {
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
setGlobalSelectedMaskIds([]);
|
||||
setSelectedPolygonIndex(0);
|
||||
}
|
||||
if (!isBooleanTool) setPendingBooleanFrameIds(null);
|
||||
}, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]);
|
||||
}, [canKeepMaskSelection, effectiveTool, isBooleanTool, setGlobalSelectedMaskIds, setPaintStrokePoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousFrameIdRef.current === frame?.id) return;
|
||||
@@ -784,6 +786,9 @@ export function CanvasArea({
|
||||
|
||||
useEffect(() => {
|
||||
const currentGlobalSelectedIds = useStore.getState().selectedMaskIds;
|
||||
if (!canKeepMaskSelection) {
|
||||
return;
|
||||
}
|
||||
const validLocalSelectedIds = selectedMaskIds.filter((id) => (
|
||||
frameMasks.some((mask) => mask.id === id)
|
||||
));
|
||||
@@ -802,11 +807,14 @@ export function CanvasArea({
|
||||
if (!isSameSelection) {
|
||||
setGlobalSelectedMaskIds(nextSelectedMaskIds);
|
||||
}
|
||||
}, [frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]);
|
||||
}, [canKeepMaskSelection, frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]);
|
||||
|
||||
useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canKeepMaskSelection) {
|
||||
return;
|
||||
}
|
||||
if (!selectedMaskId) {
|
||||
const validGlobalSelectedIds = useStore.getState().selectedMaskIds.filter((id) => (
|
||||
frameMasks.some((mask) => mask.id === id)
|
||||
@@ -833,7 +841,7 @@ export function CanvasArea({
|
||||
setSelectedPolygonIndex(0);
|
||||
setSelectedVertexIndex(null);
|
||||
}
|
||||
}, [frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
|
||||
}, [canKeepMaskSelection, frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
|
||||
|
||||
const handleWheel = (e: any) => {
|
||||
e.evt.preventDefault();
|
||||
@@ -903,7 +911,12 @@ export function CanvasArea({
|
||||
metadata: { source: 'manual', shape },
|
||||
};
|
||||
addMask(mask);
|
||||
}, [activeClass, activeTemplateId, addMask, frame?.id]);
|
||||
setSelectedMaskId(mask.id);
|
||||
setSelectedMaskIds([mask.id]);
|
||||
setGlobalSelectedMaskIds([mask.id]);
|
||||
setSelectedPolygonIndex(0);
|
||||
setSelectedVertexIndex(null);
|
||||
}, [activeClass, activeTemplateId, addMask, frame?.id, setGlobalSelectedMaskIds]);
|
||||
|
||||
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
|
||||
if (!frame?.id || !activeClass) return null;
|
||||
@@ -1113,9 +1126,10 @@ export function CanvasArea({
|
||||
}
|
||||
setSelectedMaskId(null);
|
||||
setSelectedMaskIds([]);
|
||||
setGlobalSelectedMaskIds([]);
|
||||
setSelectedPolygonIndex(0);
|
||||
setSelectedVertexIndex(null);
|
||||
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setMasks]);
|
||||
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setGlobalSelectedMaskIds, setMasks]);
|
||||
|
||||
const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => {
|
||||
if (!frame?.id || strokePoints.length === 0) return;
|
||||
@@ -1159,6 +1173,7 @@ export function CanvasArea({
|
||||
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
|
||||
setSelectedMaskId(selectedMask.id);
|
||||
setSelectedMaskIds([selectedMask.id]);
|
||||
setGlobalSelectedMaskIds([selectedMask.id]);
|
||||
setSelectedVertexIndex(null);
|
||||
return;
|
||||
}
|
||||
@@ -1167,6 +1182,7 @@ export function CanvasArea({
|
||||
if (nextMask) {
|
||||
setSelectedMaskId(nextMask.id);
|
||||
setSelectedMaskIds([nextMask.id]);
|
||||
setGlobalSelectedMaskIds([nextMask.id]);
|
||||
setSelectedPolygonIndex(0);
|
||||
setSelectedVertexIndex(null);
|
||||
}
|
||||
@@ -1194,6 +1210,7 @@ export function CanvasArea({
|
||||
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
|
||||
setSelectedMaskId(selectedMask.id);
|
||||
setSelectedMaskIds([selectedMask.id]);
|
||||
setGlobalSelectedMaskIds([selectedMask.id]);
|
||||
setSelectedVertexIndex(null);
|
||||
}
|
||||
}, [
|
||||
@@ -1212,6 +1229,7 @@ export function CanvasArea({
|
||||
image?.width,
|
||||
masks,
|
||||
selectedMask,
|
||||
setGlobalSelectedMaskIds,
|
||||
setMasks,
|
||||
stageSize.height,
|
||||
stageSize.width,
|
||||
@@ -1392,6 +1410,21 @@ export function CanvasArea({
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) {
|
||||
const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex);
|
||||
if (currentPoints.length > 3) {
|
||||
@@ -1417,15 +1450,11 @@ export function CanvasArea({
|
||||
event.preventDefault();
|
||||
finishPolygon();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setPolygonPoints([]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
|
||||
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, setGlobalSelectedMaskIds, setPaintStrokePoints, updatePolygonMask]);
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
if (!boxStart || !boxCurrent) return null;
|
||||
|
||||
@@ -160,6 +160,39 @@ describe('OntologyInspector', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not change existing masks when selecting a class without a selected mask', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: [],
|
||||
masks: [
|
||||
{
|
||||
id: 'm1',
|
||||
frameId: 'frame-1',
|
||||
pathData: 'M 0 0 Z',
|
||||
label: '旧标签',
|
||||
color: '#06b6d4',
|
||||
classId: 'old-class',
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<OntologyInspector />);
|
||||
fireEvent.click(screen.getByText('肝脏'));
|
||||
|
||||
expect(useStore.getState().activeClassId).toBe('c2');
|
||||
expect(useStore.getState().masks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'm1',
|
||||
label: '旧标签',
|
||||
color: '#06b6d4',
|
||||
classId: 'old-class',
|
||||
saveStatus: 'saved',
|
||||
saved: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies the selected class to currently selected masks', () => {
|
||||
useStore.setState({
|
||||
selectedMaskIds: ['m1'],
|
||||
|
||||
Reference in New Issue
Block a user