统一工作区清空遮罩入口

- 移除 Canvas 右下角旧清空遮罩和应用分类按钮,清空入口统一到左侧工具栏

- 清空遮罩优先作用于当前帧选中 mask,无选中时作用于当前帧全部 mask

- 目标 mask 无传播链结果时直接清当前帧,有传播链结果时弹窗选择只清当前帧、清空传播所有帧或取消

- 保留布尔工具右下角合并/去除操作区,避免旧分类按钮误改整帧

- 更新 Canvas、工具栏、工作区测试,覆盖直接清空、传播链范围选择和取消路径

- 同步更新前端审计、需求冻结、设计冻结、测试计划和 AGENTS 说明
This commit is contained in:
2026-05-03 21:06:03 +08:00
parent 3e998b9d6b
commit a22af5f7c8
11 changed files with 263 additions and 169 deletions

View File

@@ -1380,78 +1380,6 @@ describe('CanvasArea', () => {
vi.useRealTimers();
});
it('applies the selected class to current-frame masks and linked propagation masks', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
masks: [
{
id: 'm1',
frameId: 'frame-1',
annotationId: '99',
pathData: 'M 0 0 Z',
label: '旧标签',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'm2',
frameId: 'frame-2',
annotationId: '100',
pathData: 'M 1 1 Z',
label: '旧传播标签',
color: '#06b6d4',
metadata: { source_annotation_id: 99, source_mask_id: 'annotation-99' },
saved: true,
saveStatus: 'saved',
},
{
id: 'm3',
frameId: 'frame-2',
annotationId: '101',
pathData: 'M 2 2 Z',
label: '无关区域',
color: '#ffffff',
metadata: { source_annotation_id: 101 },
saved: true,
saveStatus: 'saved',
},
],
});
render(<CanvasArea activeTool="move" frame={frame} />);
fireEvent.click(screen.getByRole('button', { name: '应用分类' }));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
templateId: '2',
classId: 'c1',
className: '胆囊',
classZIndex: 20,
classMaskId: 1,
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
saved: false,
}));
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
classId: 'c1',
className: '胆囊',
classMaskId: 1,
label: '胆囊',
color: '#ff0000',
saveStatus: 'dirty',
saved: false,
}));
expect(useStore.getState().masks[2]).toEqual(expect.objectContaining({
label: '无关区域',
color: '#ffffff',
saveStatus: 'saved',
saved: true,
}));
});
it('renders unselected masks by semantic tree layer priority', () => {
useStore.setState({
selectedMaskIds: [],
@@ -1481,18 +1409,17 @@ describe('CanvasArea', () => {
expect(paths.map((path) => path.getAttribute('data-fill'))).toEqual(['#22c55e', '#ef4444']);
});
it('delegates clear to the workspace handler so saved annotations can be deleted', () => {
const onClearMasks = vi.fn();
it('does not render duplicate bottom-right clear or class action buttons', () => {
useStore.setState({
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
masks: [
{ id: 'm1', frameId: 'frame-1', pathData: 'M 0 0 Z', label: 'A', color: '#fff' },
],
});
render(<CanvasArea activeTool="move" frame={frame} onClearMasks={onClearMasks} />);
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
render(<CanvasArea activeTool="move" frame={frame} />);
expect(onClearMasks).toHaveBeenCalled();
expect(useStore.getState().masks).toHaveLength(1);
expect(screen.queryByRole('button', { name: '清空遮罩' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '应用分类' })).not.toBeInTheDocument();
});
});

View File

@@ -9,7 +9,6 @@ import type { Frame, Mask } from '../store/useStore';
interface CanvasAreaProps {
activeTool: string;
frame: Frame | null;
onClearMasks?: () => void;
onDeleteMaskAnnotations?: (annotationIds: string[]) => Promise<void> | void;
}
@@ -417,7 +416,7 @@ function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
return polygonClipping.intersection(first, second).length > 0;
}
export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnotations }: CanvasAreaProps) {
export function CanvasArea({ activeTool, frame, onDeleteMaskAnnotations }: CanvasAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [scale, setScale] = useState(1);
@@ -448,7 +447,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const masks = useStore((state) => state.masks);
const addMask = useStore((state) => state.addMask);
const updateMask = useStore((state) => state.updateMask);
const clearMasks = useStore((state) => state.clearMasks);
const setMasks = useStore((state) => state.setMasks);
const setGlobalSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
@@ -971,37 +969,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
}
}, [activeClass, activeTemplateId, addMask, aiModel, frame?.height, frame?.id, frame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, samCandidateMaskId, setMasks, updateMask]);
const handleApplyActiveClass = () => {
if (!frame?.id || !activeClass) return;
const seedIds = selectedMaskIds.length > 0
? selectedMaskIds
: frameMasks.map((mask) => mask.id);
const targetIds = findPropagationChainMaskIds(seedIds, masks);
setMasks(masks.map((mask) => {
if (!targetIds.has(mask.id)) return mask;
return {
...mask,
templateId: activeTemplateId || mask.templateId,
classId: activeClass.id,
className: activeClass.name,
classZIndex: activeClass.zIndex,
classMaskId: activeClass.maskId,
label: activeClass.name,
color: activeClass.color,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: Boolean(mask.annotationId) ? false : mask.saved,
};
}));
};
const handleClearMasks = () => {
if (onClearMasks) {
onClearMasks();
return;
}
clearMasks();
};
const deleteMasksById = useCallback((maskIds: string[]) => {
if (maskIds.length === 0) return;
const idSet = expandedPropagationDeletionMaskIds(maskIds, masks);
@@ -1772,36 +1739,20 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
<span>: {dirtyMaskCount}</span>
</div>
{frameMasks.length > 0 && (
{frameMasks.length > 0 && isBooleanTool && (
<div className="absolute bottom-4 right-4 flex gap-2">
{isBooleanTool && (
<div className="flex items-center gap-2">
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
{booleanSelectedMasks.length}
</span>
<button
onClick={handleBooleanOperation}
disabled={booleanSelectedMasks.length < 2}
className="text-xs bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/20 px-3 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-500/10"
>
{effectiveTool === 'area_merge' ? '合并选中' : '从主区域去除'}
</button>
</div>
)}
{activeClass && (
<div className="flex items-center gap-2">
<span className="text-xs bg-white/5 text-gray-300 border border-white/10 px-2.5 py-1.5 rounded">
{booleanSelectedMasks.length}
</span>
<button
onClick={handleApplyActiveClass}
className="text-xs bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/20 px-3 py-1.5 rounded transition-colors"
onClick={handleBooleanOperation}
disabled={booleanSelectedMasks.length < 2}
className="text-xs bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-300 border border-emerald-500/20 px-3 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-500/10"
>
{effectiveTool === 'area_merge' ? '合并选中' : '从主区域去除'}
</button>
)}
<button
onClick={handleClearMasks}
className="text-xs bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-3 py-1.5 rounded transition-colors"
>
</button>
</div>
</div>
)}
</div>

View File

@@ -73,7 +73,7 @@ describe('ToolsPalette', () => {
const onClearMasks = vi.fn();
render(<ToolsPalette activeTool="move" setActiveTool={vi.fn()} onClearMasks={onClearMasks} />);
fireEvent.click(screen.getByTitle('清空遮罩(含传播帧)'));
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(onClearMasks).toHaveBeenCalled();
});
@@ -85,7 +85,7 @@ describe('ToolsPalette', () => {
const circleButton = screen.getByTitle('创建圆 (O)');
const brushButton = screen.getByTitle('画笔 (B)');
const removeButton = screen.getByTitle('重叠区域去除 (-)');
const clearButton = screen.getByTitle('清空遮罩(含传播帧)');
const clearButton = screen.getByTitle('清空遮罩');
const importButton = screen.getByTitle('导入 GT Mask');
expect(separators).toHaveLength(2);

View File

@@ -104,7 +104,7 @@ export function ToolsPalette({
<button
onClick={onClearMasks}
disabled={!onClearMasks}
title="清空遮罩(含传播帧)"
title="清空遮罩"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-35 disabled:cursor-not-allowed"
>
<Trash2 size={16} strokeWidth={2.1} />

View File

@@ -615,8 +615,9 @@ describe('VideoWorkspace', () => {
});
});
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(useStore.getState().masks).toEqual([]);
});
@@ -659,7 +660,9 @@ describe('VideoWorkspace', () => {
});
});
fireEvent.click(screen.getByRole('button', { name: '清空遮罩' }));
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.getByText('选择清空范围')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清空传播所有帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10');
@@ -667,6 +670,130 @@ describe('VideoWorkspace', () => {
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('can clear only the current frame when current masks have propagated results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-1',
annotationId: '1',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Seed',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
},
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '只清当前帧' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('1'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-10' }),
]);
});
it('clears only selected current-frame masks when a selected mask has no propagated results', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{ id: 'annotation-2', annotationId: '2', frameId: '10', pathData: 'M 2 2 Z', label: 'Standalone', color: '#ff0000', saved: true, saveStatus: 'saved' },
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
selectedMaskIds: ['annotation-2'],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('2'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('1');
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('10');
expect(useStore.getState().masks).toEqual([
expect.objectContaining({ id: 'annotation-1' }),
expect.objectContaining({ id: 'annotation-10' }),
]);
});
it('can cancel current-frame propagated clear confirmation', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-1', annotationId: '1', frameId: '10', pathData: 'M 0 0 Z', label: 'Seed', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{
id: 'annotation-10',
annotationId: '10',
frameId: '11',
pathData: 'M 1 1 Z',
label: 'Propagated',
color: '#06b6d4',
saved: true,
saveStatus: 'saved',
metadata: { source: 'sam2_propagation', source_annotation_id: 1, source_mask_id: 'annotation-1', propagation_seed_key: 'annotation:1' },
},
],
});
});
fireEvent.click(screen.getByTitle('清空遮罩'));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks).toHaveLength(2);
expect(screen.queryByText('选择清空范围')).not.toBeInTheDocument();
});
it('clears masks across the selected frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },

View File

@@ -52,6 +52,14 @@ type ClearRangeConfirmState = {
rangeEndIndex: number;
mode: ClearRangeMode;
};
type CurrentClearConfirmState = {
currentFrameNumber: number;
scopeLabel: string;
currentMaskIds: string[];
propagatedMaskIds: string[];
currentMaskCount: number;
propagatedMaskCount: number;
};
type GtUnknownPolicy = 'discard' | 'undefined';
type ExportScope = 'all' | 'range' | 'current';
type ExportPreviewPolygon = {
@@ -489,6 +497,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
const [pendingCurrentClearConfirm, setPendingCurrentClearConfirm] = useState<CurrentClearConfirmState | null>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
@@ -818,11 +827,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]);
const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return;
const frameMasks = masks.filter((mask) => mask.frameId === currentFrame.id);
const maskIdsToClear = expandedPropagationDeletionMaskIds(frameMasks.map((mask) => mask.id), masks);
const masksToClear = masks.filter((mask) => maskIdsToClear.has(mask.id));
const executeClearCurrentMasks = useCallback(async (maskIdsToClear: string[], messageScope: string) => {
const latestMasks = useStore.getState().masks;
const maskIdSet = new Set(maskIdsToClear);
const masksToClear = latestMasks.filter((mask) => maskIdSet.has(mask.id));
if (masksToClear.length === 0) {
setStatusMessage('没有可清空的遮罩');
setPendingCurrentClearConfirm(null);
return;
}
const annotationIds = Array.from(new Set(
masksToClear
.map((mask) => mask.annotationId)
@@ -830,21 +843,57 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
));
setIsSaving(true);
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注和关联传播帧...' : '正在清空本帧遮罩和关联传播帧...');
setStatusMessage(annotationIds.length > 0 ? `正在删除${messageScope}的已保存标注...` : `正在清空${messageScope}的本地遮罩...`);
try {
await deleteAnnotationsIfExist(annotationIds);
setMasks(masks.filter((mask) => !maskIdsToClear.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdsToClear.has(id)));
const afterDeleteMasks = useStore.getState().masks.filter((mask) => !maskIdSet.has(mask.id));
setMasks(afterDeleteMasks);
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !maskIdSet.has(id)));
setStatusMessage(annotationIds.length > 0
? `删除 ${annotationIds.length}后端标注,已同步清空关联传播帧`
: '已清空本帧未保存遮罩和关联传播帧');
? `清空${messageScope}${masksToClear.length} 个遮罩,其中后端标注 ${annotationIds.length}`
: `已清空${messageScope}${masksToClear.length} 个本地遮罩`);
setPendingCurrentClearConfirm(null);
} catch (err) {
console.error('Delete annotations failed:', err);
setStatusMessage('删除失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [currentFrame, masks, setMasks, setSelectedMaskIds]);
}, [setMasks, setSelectedMaskIds]);
const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return;
const latestMasks = useStore.getState().masks;
const frameMasks = latestMasks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (frameMasks.length === 0) {
setStatusMessage('当前帧没有可清空的遮罩');
return;
}
const selectedIdSet = new Set(useStore.getState().selectedMaskIds);
const selectedFrameMasks = frameMasks.filter((mask) => selectedIdSet.has(mask.id));
const targetFrameMasks = selectedFrameMasks.length > 0 ? selectedFrameMasks : frameMasks;
const scopeLabel = selectedFrameMasks.length > 0 ? `${currentFrameNumber} 帧选中 mask` : `${currentFrameNumber}`;
const currentMaskIds = targetFrameMasks.map((mask) => mask.id);
const propagatedMaskIds = Array.from(expandedPropagationDeletionMaskIds(currentMaskIds, latestMasks));
const propagatedOutsideCurrentCount = latestMasks.filter((mask) => (
propagatedMaskIds.includes(mask.id)
&& String(mask.frameId) !== String(currentFrame.id)
)).length;
if (propagatedOutsideCurrentCount === 0) {
await executeClearCurrentMasks(currentMaskIds, scopeLabel);
return;
}
setPendingCurrentClearConfirm({
currentFrameNumber,
scopeLabel,
currentMaskIds,
propagatedMaskIds,
currentMaskCount: currentMaskIds.length,
propagatedMaskCount: propagatedMaskIds.length,
});
}, [currentFrame, currentFrameNumber, executeClearCurrentMasks]);
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
const frameIdsToClear = new Set(request.frameIdsToClear);
@@ -1948,7 +1997,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<CanvasArea
activeTool={activeTool}
frame={currentFrame}
onClearMasks={handleClearCurrentFrameMasks}
onDeleteMaskAnnotations={handleDeleteMaskAnnotations}
/>
</div>
@@ -1957,6 +2005,47 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<OntologyInspector />
</div>
{pendingCurrentClearConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-md rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingCurrentClearConfirm.scopeLabel}
</p>
<div className="mt-3 rounded-md border border-white/10 bg-white/[0.03] p-3 text-xs leading-relaxed text-gray-400">
{pendingCurrentClearConfirm.currentMaskCount} mask + {pendingCurrentClearConfirm.propagatedMaskCount} mask
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={() => setPendingCurrentClearConfirm(null)}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
disabled={isSaving}
>
</button>
<button
type="button"
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.currentMaskIds, pendingCurrentClearConfirm.scopeLabel)}
className="rounded border border-red-400/30 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-100 hover:bg-red-500/20 disabled:opacity-60"
disabled={isSaving}
>
</button>
<button
type="button"
onClick={() => void executeClearCurrentMasks(pendingCurrentClearConfirm.propagatedMaskIds, '当前帧及传播链')}
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:opacity-60"
disabled={isSaving}
>
</button>
</div>
</div>
</div>
)}
{pendingClearRangeConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-md rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">