PPT-style Multi-selection, Group Move, and Batch Deletion for Cornerstone Annotations
🧐 해결한 문제
많은 의료진이 기존 Annotation 제어 방식의 비효율성을 호소했습니다. 수많은 ROI가 그려진 복잡한 진단 케이스에서 기존 방식은 워크플로우에 병목을 만듭니다.
- 불편한 동선: 어노테이션을 지우려면
EraseTool로 모드를 전환한 뒤, 대상 객체의 선을 일일이 정확하게 조준하여 클릭해야 합니다. - 작업 피로도: 수십 개의 어노테이션을 지워야 할 때 개별 제어 방식은 마우스 동선을 극도로 낭비하게 만듭니다.
PowerPoint나 Figma처럼 바탕을 드래그하여 객체들을 다중 선택하고, 그룹으로 이동하거나 Delete 키로 한 번에 지울 수 있도록 직접 구현했습니다.

실제 병원 실무자들에게 압도적으로 좋은 평가를 받으며 작업 생산성을 높여주었습니다. 이 구현 과정에서 얻은 핵심 기술 포인트들을 공유합니다.
Note on Codebase: 현재 저희 제품은 Deprecated된 구버전의
cornerstone-tools를 사용 중이며, 아래 코드는 React-TypeScript 기반의 커스텀 Hook 형태로 구현되어 있습니다.
🛠 구현 상세
핵심 로직 1: 기존 툴과의 이벤트 충돌 방지 (Event Guard)
바탕화면 드래그 이벤트를 캡처할 때, Window/Level 조절이나 새로운 어노테이션 드로잉 등 다른 툴과의 충돌을 방지하기 위해 mousedown 단계에서 엄격한 예외 처리가 필요합니다.

예외처리 하지 않을 시 발생하는 문제
const handleMouseDown = (e) => {
if (e.button !== 0) return; // 좌클릭만 허용
if (choiceTool !== 'default') return; // 선택/기본 모드일 때만 드래그 박스 허용
// 사용자가 특정 객체를 잡고 개별적으로 옮기려는 의도라면, 드래그 박스 생성을 차단
let isClickingOnAnnotation = false;
const tools = cornerstoneTools.store.state.tools;
tools.forEach((tool) => {
const toolState = cornerstoneTools.getToolState(element, tool.name);
if (toolState && toolState.data) {
if (toolState.data.find(anno => anno.active)) isClickingOnAnnotation = true;
}
});
if (isClickingOnAnnotation) return;
isMouseDownRef.current = true;
};
const handleMouseDown = (e) => {
if (e.button !== 0) return; // 좌클릭만 허용
if (choiceTool !== 'default') return; // 선택/기본 모드일 때만 드래그 박스 허용
// 사용자가 특정 객체를 잡고 개별적으로 옮기려는 의도라면, 드래그 박스 생성을 차단
let isClickingOnAnnotation = false;
const tools = cornerstoneTools.store.state.tools;
tools.forEach((tool) => {
const toolState = cornerstoneTools.getToolState(element, tool.name);
if (toolState && toolState.data) {
if (toolState.data.find(anno => anno.active)) isClickingOnAnnotation = true;
}
});
if (isClickingOnAnnotation) return;
isMouseDownRef.current = true;
};
Visual Feedback: Rubber Band Selection UI
드래그 선택 영역을 시각적으로 표시하는 "고무줄 박스(Rubber Band)"는 별도의 DOM 레이어로 구현합니다. useDragSelection Hook이 dragBox 상태(top, left, width, height)를 반환하면, 뷰어 컴포넌트는 이를 조건부로 렌더링합니다.
{dragBox && (
<div
style={{
position: 'fixed', // e.clientX/Y 기반의 화면 절대 좌표 사용
backgroundColor: 'rgba(0, 120, 215, 0.3)', // 반투명 파란색
border: '1px solid #0078D7',
top: dragBox.top,
left: dragBox.left,
width: dragBox.width,
height: dragBox.height,
pointerEvents: 'none', // ⚠️ 핵심: 이 오버레이가 마우스 이벤트를 가로채지 않도록 투과 처리
zIndex: 9999,
}}
/>
)}
{dragBox && (
<div
style={{
position: 'fixed', // e.clientX/Y 기반의 화면 절대 좌표 사용
backgroundColor: 'rgba(0, 120, 215, 0.3)', // 반투명 파란색
border: '1px solid #0078D7',
top: dragBox.top,
left: dragBox.left,
width: dragBox.width,
height: dragBox.height,
pointerEvents: 'none', // ⚠️ 핵심: 이 오버레이가 마우스 이벤트를 가로채지 않도록 투과 처리
zIndex: 9999,
}}
/>
)}
여기서 pointerEvents: 'none'은 단순한 스타일 속성이 아니라 기능의 정확성을 결정하는 핵심 설정입니다. 이 속성이 없으면 드래그 중에 생성된 오버레이 div가 마우스 이벤트를 가로채어 Cornerstone의 mousemove 및 mouseup 이벤트가 정상적으로 발생하지 않습니다.
핵심 로직 2: selected 상태 설계 — 그룹 이동과 일괄 삭제의 기반
구현 과정에서 가장 중요했던 설계 결정은 기존의 active 프로퍼티 대신 커스텀 selected 프로퍼티를 도입한 것입니다.
현재 Cornerstone 생태계에서는 마우스를 Annotation 위로 올리기만 해도(Hover) active 상태가 true로 변경됩니다. 이를 다중 선택 플래그로 사용할 경우, Delete 키를 누를 때 마우스 커서 아래에 우연히 있던 Annotation까지 의도치 않게 삭제되는 버그가 발생합니다. Hover 상태(active)와 명시적 다중 선택 상태(selected)를 엄격하게 분리해야 합니다.

이 selected 프로퍼티는 이후의 그룹 이동과 일괄 삭제 두 기능 모두의 기반이 됩니다.
if (isFullyContained) {
anno.selected = true; // active 대신 selected 사용
anno.color = '#00FF00';
needsUpdate = true;
} else {
if (!isMultiSelect && anno.selected) {
anno.selected = false;
delete anno.color;
needsUpdate = true;
}
}
if (isFullyContained) {
anno.selected = true; // active 대신 selected 사용
anno.color = '#00FF00';
needsUpdate = true;
} else {
if (!isMultiSelect && anno.selected) {
anno.selected = false;
delete anno.color;
needsUpdate = true;
}
}
① 그룹 이동 (Group Move)
selected 상태인 어노테이션들을 통째로 드래그하여 이동합니다. mousedown 시점에 "이미 선택된 객체 위를 클릭했는가"를 감지하여 그룹 이동 모드로 전환되며, 두 가지 핵심 포인트가 있습니다.
- 핸들 클릭 방어:
isHandleClicked플래그를 통해 개별 핸들 조작 시에는 그룹 이동이 개입하지 않도록 합니다. - 불변 원본(Immutable Origin) 패턴: 이동 시작 시점의 핸들 좌표를 딥카피로 보존하고,
mousemove에서는 원본 좌표에 순수한delta(dx, dy)를 더하는 방식으로 누적 오차 없이 정밀한 이동을 보장합니다.

// mousedown 핸들러 내부
// 조건: 이미 선택된 그룹 위를 클릭했고, 핸들을 직접 누른 게 아닐 때
if (clickedAnnotation?.selected && allSelectedAnnos.length > 1 && !isMultiSelect && !isHandleClicked) {
e.stopImmediatePropagation();
e.preventDefault();
const startImagePt = cornerstone.pageToPixel(element, e.pageX, e.pageY);
let hasMoved = false;
// [핵심] 이동 전 원본 좌표를 딥카피로 보존
const initialStates = allSelectedAnnos.map(anno => ({
anno,
origHandles: JSON.parse(JSON.stringify(anno.handles))
}));
// 핸들 구조를 재귀적으로 순회하며 좌표를 이동시키는 함수
// Freehand처럼 점이 배열인 경우와 start/end처럼 단일 객체인 경우를 모두 처리
const shiftHandles = (orig, target, dx, dy) => {
Object.keys(orig).forEach(key => {
if (key === 'boundingBox') return;
if (Array.isArray(orig[key])) {
orig[key].forEach((pt, i) => {
if (typeof pt.x === 'number') {
target[key][i].x = pt.x + dx;
target[key][i].y = pt.y + dy;
}
});
} else if (orig[key] && typeof orig[key] === 'object') {
if (typeof orig[key].x === 'number') {
target[key].x = orig[key].x + dx;
target[key].y = orig[key].y + dy;
}
shiftHandles(orig[key], target[key], dx, dy);
}
});
};
const handleGroupMove = (moveEvent) => {
hasMoved = true;
const currentImagePt = cornerstone.pageToPixel(element, moveEvent.pageX, moveEvent.pageY);
const dx = currentImagePt.x - startImagePt.x;
const dy = currentImagePt.y - startImagePt.y;
// 원본 좌표(origHandles) 기준으로 delta를 더해 현재 위치를 갱신
initialStates.forEach(({ anno, origHandles }) => {
shiftHandles(origHandles, anno.handles, dx, dy);
});
cornerstone.updateImage(element);
};
const handleGroupUp = () => {
window.removeEventListener('mousemove', handleGroupMove);
window.removeEventListener('mouseup', handleGroupUp);
// 실제로 움직이지 않았다면 → 클릭으로 간주하여 다른 어노테이션의 선택을 해제
if (!hasMoved) {
allSelectedAnnos.forEach(anno => {
if (anno !== clickedAnnotation) {
anno.selected = false;
delete anno.color;
}
});
cornerstone.updateImage(element);
}
};
window.addEventListener('mousemove', handleGroupMove);
window.addEventListener('mouseup', handleGroupUp);
return; // 이후 단일 선택 로직이 실행되지 않도록 차단
}
// mousedown 핸들러 내부
// 조건: 이미 선택된 그룹 위를 클릭했고, 핸들을 직접 누른 게 아닐 때
if (clickedAnnotation?.selected && allSelectedAnnos.length > 1 && !isMultiSelect && !isHandleClicked) {
e.stopImmediatePropagation();
e.preventDefault();
const startImagePt = cornerstone.pageToPixel(element, e.pageX, e.pageY);
let hasMoved = false;
// [핵심] 이동 전 원본 좌표를 딥카피로 보존
const initialStates = allSelectedAnnos.map(anno => ({
anno,
origHandles: JSON.parse(JSON.stringify(anno.handles))
}));
// 핸들 구조를 재귀적으로 순회하며 좌표를 이동시키는 함수
// Freehand처럼 점이 배열인 경우와 start/end처럼 단일 객체인 경우를 모두 처리
const shiftHandles = (orig, target, dx, dy) => {
Object.keys(orig).forEach(key => {
if (key === 'boundingBox') return;
if (Array.isArray(orig[key])) {
orig[key].forEach((pt, i) => {
if (typeof pt.x === 'number') {
target[key][i].x = pt.x + dx;
target[key][i].y = pt.y + dy;
}
});
} else if (orig[key] && typeof orig[key] === 'object') {
if (typeof orig[key].x === 'number') {
target[key].x = orig[key].x + dx;
target[key].y = orig[key].y + dy;
}
shiftHandles(orig[key], target[key], dx, dy);
}
});
};
const handleGroupMove = (moveEvent) => {
hasMoved = true;
const currentImagePt = cornerstone.pageToPixel(element, moveEvent.pageX, moveEvent.pageY);
const dx = currentImagePt.x - startImagePt.x;
const dy = currentImagePt.y - startImagePt.y;
// 원본 좌표(origHandles) 기준으로 delta를 더해 현재 위치를 갱신
initialStates.forEach(({ anno, origHandles }) => {
shiftHandles(origHandles, anno.handles, dx, dy);
});
cornerstone.updateImage(element);
};
const handleGroupUp = () => {
window.removeEventListener('mousemove', handleGroupMove);
window.removeEventListener('mouseup', handleGroupUp);
// 실제로 움직이지 않았다면 → 클릭으로 간주하여 다른 어노테이션의 선택을 해제
if (!hasMoved) {
allSelectedAnnos.forEach(anno => {
if (anno !== clickedAnnotation) {
anno.selected = false;
delete anno.color;
}
});
cornerstone.updateImage(element);
}
};
window.addEventListener('mousemove', handleGroupMove);
window.addEventListener('mouseup', handleGroupUp);
return; // 이후 단일 선택 로직이 실행되지 않도록 차단
}
② Delete 키 연동 및 안전한 일괄 삭제
selected 프로퍼티를 기준으로 선택된 객체들을 일괄 삭제합니다. 전역 keydown 이벤트의 Delete 키에 바인딩하여 PPT와 같은 직관적인 그룹 삭제가 가능합니다.
다중 삭제 시 인덱스 꼬임(Index Shifting) 버그를 방지하기 위해 배열을 뒤에서부터 순회하며 splice하는 것이 핵심입니다.
const deletedSelectedAnnotation = () => {
const wadoElements = _getWadoElements();
wadoElements.forEach((value) => {
const element = value.element;
const tools = cornerstoneTools.store.state.tools;
let isDeleted = false;
tools.forEach((tool) => {
const toolState = cornerstoneTools.getToolState(element, tool.name);
if (toolState && toolState.data) {
// 인덱스 밀림을 방지하기 위해 배열을 뒤에서부터 순회하며 삭제
for (let i = toolState.data.length - 1; i >= 0; i--) {
if (toolState.data[i].selected) {
toolState.data.splice(i, 1);
isDeleted = true;
}
}
}
});
if (isDeleted) cornerstone.updateImage(element);
});
};
// 이 함수를 전역 keydown 이벤트의 'Delete' 키에 바인딩하여 사용합니다.
const deletedSelectedAnnotation = () => {
const wadoElements = _getWadoElements();
wadoElements.forEach((value) => {
const element = value.element;
const tools = cornerstoneTools.store.state.tools;
let isDeleted = false;
tools.forEach((tool) => {
const toolState = cornerstoneTools.getToolState(element, tool.name);
if (toolState && toolState.data) {
// 인덱스 밀림을 방지하기 위해 배열을 뒤에서부터 순회하며 삭제
for (let i = toolState.data.length - 1; i >= 0; i--) {
if (toolState.data[i].selected) {
toolState.data.splice(i, 1);
isDeleted = true;
}
}
}
});
if (isDeleted) cornerstone.updateImage(element);
});
};
// 이 함수를 전역 keydown 이벤트의 'Delete' 키에 바인딩하여 사용합니다.
🎨 추가: PPT-style Selection Handle UI
선택된 어노테이션에 꼭짓점 핸들 점과 점선 바운딩 박스를 별도 DOM 레이어로 오버레이하는 시각적 피드백도 함께 구현했습니다.

핵심 요약
| 구현 포인트 | 핵심 내용 |
|---|---|
| Event Guard | mousedown 단계에서 active 어노테이션 존재 여부로 드래그 박스 진입을 차단 |
| Rubber Band UI | pointerEvents: none 없이는 Cornerstone 이벤트가 깨짐 |
selected vs active | Hover(active)와 다중선택(selected)을 반드시 분리해야 의도치 않은 삭제를 막을 수 있음 |
| Immutable Origin 패턴 | 딥카피 원본에 delta를 더하는 방식으로 누적 오차 방지 |
| 역순 splice | 배열 앞에서부터 삭제하면 인덱스가 밀리므로 반드시 뒤에서부터 순회 |