문제 상황
SVG 에디터의 인터랙션 문제는 대부분 “가끔 이상하다”는 형태로 나타납니다. 도형이 조금 늦게 따라오거나, resize 중에 선택이 풀리거나, 드래그가 끝났는데도 마우스를 따라오는 문제가 생깁니다. 원인은 보통 아래 중 여러 곳에 걸쳐 있었습니다.
- 브라우저 좌표를 SVG 좌표로 변환하는 과정
- pointer capture 유무
- active tool 상태
- 선택 상태와 drag 상태의 순서
- keyboard modifier 처리
- 히스토리 기록 타이밍
처음에는 문제가 보이는 위치에서 바로 고치려고 했습니다. resize가 이상하면 resize 함수만 보고, drag가 이상하면 move 함수만 보는 식이었습니다. 하지만 편집기 인터랙션은 여러 상태가 동시에 움직이기 때문에 증상 위치와 원인 위치가 다를 때가 많았습니다.
그래서 디버깅 순서를 정했습니다. 문제가 생기면 항상 아래 순서로 좁혔습니다.
- 포인터 좌표가 맞는가
- 이벤트 흐름이 끊기지 않았는가
- 현재 tool과 mode가 맞는가
- 선택 상태가 먼저 바뀌었는가
- 문서 상태 변경과 히스토리 기록이 한 번만 일어났는가
1단계: 좌표 변환 확인
SVG 인터랙션에서 가장 먼저 확인할 것은 좌표입니다.
마우스 이벤트의 clientX, clientY는 브라우저 viewport 기준입니다.
하지만 도형을 이동시키려면 SVG 내부 좌표계로 바꿔야 합니다.
function toSvgPoint(event: PointerEvent, svg: SVGSVGElement) {
const point = svg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const matrix = svg.getScreenCTM();
if (!matrix) return null;
return point.matrixTransform(matrix.inverse());
}
문제가 생겼을 때는 먼저 이 값부터 로그로 확인했습니다.
console.table({
clientX: event.clientX,
clientY: event.clientY,
svgX: svgPoint.x,
svgY: svgPoint.y,
});
좌표가 틀리면 이후 로직은 모두 정상이어도 결과가 틀어집니다. 특히 zoom, scroll, transform이 들어간 화면에서는 좌표 변환 문제를 먼저 배제해야 합니다. 이 내용은 SVG 좌표 변환에서 포인터 위치가 어긋나는 문제에서 더 자세히 다뤘습니다.
2단계: pointer capture 확인
두 번째로 확인한 것은 이벤트가 끝까지 들어오는지였습니다.
드래그 중 포인터가 도형 밖으로 나가면 pointermove나 pointerup이 기대한 대상에 들어오지 않을 수 있습니다.
그러면 drag session이 종료되지 않고 남습니다.
function onPointerDown(event: PointerEvent) {
const target = event.currentTarget as SVGElement;
target.setPointerCapture(event.pointerId);
startDrag(event);
}
function onPointerUp(event: PointerEvent) {
const target = event.currentTarget as SVGElement;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
endDrag(event);
}
문제가 생겼을 때는 pointerdown, pointermove, pointerup, pointercancel이 같은 pointerId로 이어지는지 확인했습니다.
function logPointer(label: string, event: PointerEvent) {
console.log(label, {
pointerId: event.pointerId,
pointerType: event.pointerType,
buttons: event.buttons,
target: (event.target as Element).tagName,
});
}
pointerup이 누락되면 대부분 상태 정리가 되지 않습니다.
이때 resize나 drag 계산식을 고쳐도 문제가 해결되지 않습니다.
pointerup이 들어오는지부터 확인해야 합니다.
3단계: active tool 확인
SVG 에디터에서는 같은 클릭이라도 현재 도구에 따라 의미가 달라집니다. 선택 도구에서는 도형 선택이고, 선 도구에서는 점 추가이며, 이동 모드에서는 pan일 수 있습니다.
그래서 이벤트 핸들러 안에서 현재 tool을 명시적으로 분기했습니다.
function handleCanvasPointerDown(event: PointerEvent) {
switch (editorState.activeTool) {
case "select":
return startSelection(event);
case "line":
return appendLinePoint(event);
case "pan":
return startPan(event);
}
}
디버깅할 때는 이벤트마다 activeTool, selectedIds, dragSession을 함께 찍었습니다.
포인터 좌표만 보면 정상인데, 실제로는 tool이 바뀌지 않아 다른 핸들러가 실행되는 경우가 있었습니다.
이 기준은 수학교구 SVG 캔버스의 인터랙션 구조와도 연결됩니다. 그 글에서 포인터와 키보드가 같은 interaction state를 보도록 바꾼 과정을 다뤘습니다.
4단계: 선택과 drag 시작 순서
가장 자주 꼬인 부분은 선택과 drag 시작 순서였습니다. 사용자가 선택되지 않은 도형을 바로 드래그할 때, 먼저 선택을 바꾸고 그 다음 drag session을 만들어야 합니다.
function startShapeDrag(shapeId: string, point: Point) {
if (!editorState.selectedIds.includes(shapeId)) {
editorState.selectedIds = [shapeId];
}
editorState.dragSession = {
targetIds: editorState.selectedIds,
startPoint: point,
};
}
순서가 반대면 문제가 생깁니다. drag session을 먼저 만들면 이전 선택 목록을 기준으로 이동이 시작됩니다. 그 뒤 선택 상태가 바뀌면 화면에서는 새 도형이 선택된 것처럼 보이지만 실제 이동 대상은 이전 도형이 됩니다.
이런 종류의 버그는 화면만 보면 헷갈립니다. 그래서 drag 시작 시점에는 반드시 snapshot을 남겼습니다.
console.log("drag:start", {
shapeId,
selectedIds: [...editorState.selectedIds],
startPoint: point,
});
5단계: 히스토리 기록 타이밍
마지막으로 확인한 것은 히스토리 기록 타이밍입니다.
드래그 중 pointermove마다 히스토리를 쌓으면 undo를 한 번 눌렀을 때 아주 조금만 되돌아갑니다.
반대로 pointerup에서만 기록하려고 하면 드래그 시작 전 상태를 잃기 쉽습니다.
제가 사용한 기준은 시작 시점에 이전 상태를 보관하고, 종료 시점에 한 번만 기록하는 방식입니다.
function startDrag(point: Point) {
editorState.dragSession = {
startPoint: point,
before: cloneDocument(documentState),
};
}
function endDrag() {
const session = editorState.dragSession;
if (!session) return;
pushHistory({
before: session.before,
after: cloneDocument(documentState),
reason: "move",
});
editorState.dragSession = null;
}
resize도 같은 기준을 적용했습니다.
계산은 pointermove마다 하지만, 히스토리에는 하나의 사용자 작업으로 들어가야 합니다.
이 내용은 SVG 도형 리사이즈 핸들 제약 조건 정리와 SVG 에디터 undo/redo 스냅샷 설계의 중간 지점에 있는 문제였습니다.
디버깅 로그 형식
인터랙션 문제를 잡을 때 단순 문자열 로그는 금방 한계가 옵니다. 저는 이벤트 단계별로 같은 모양의 로그를 남기는 편이 더 도움이 됐습니다.
function debugInteraction(label: string, payload: Record<string, unknown>) {
if (!isDebugMode) return;
console.log(`[editor:${label}]`, {
activeTool: editorState.activeTool,
selectedIds: [...editorState.selectedIds],
drag: editorState.dragSession?.targetIds,
...payload,
});
}
이렇게 해두면 문제가 생겼을 때 “좌표 문제인지, tool 문제인지, 선택 순서 문제인지”를 한 화면에서 비교할 수 있습니다.
정리
이번 구현에서 사용한 디버깅 기준은 다음과 같습니다.
- 좌표 변환이 맞는지 먼저 확인한다.
pointerdown부터pointerup까지 같은 흐름으로 들어오는지 본다.- 현재 active tool과 실행된 핸들러가 일치하는지 확인한다.
- 선택 상태를 갱신한 뒤 drag session을 만든다.
- 히스토리는 사용자 작업 단위로 한 번만 기록한다.
- 로그는 매번 같은 구조로 남긴다.
이 순서는 MathCanvas 프로젝트에서 교구 조작 버그를 고치면서 정했습니다.