## 2026-05-30 — 사이드바 워크스페이스 reorder를 포인터 기반 제스처로 재구현 (Edge "분할 뷰" 오버레이 회피)

### 배경

사이드바의 워크스페이스(프로젝트) 순서 변경이 HTML5 drag-and-drop으로 만들어져 있었는데 두 가지 큰 문제가 있었다:

1. **드롭 타겟이 항목 사이의 8px 갭에만 있었다**. 사용자가 픽셀 단위로 정확히 갭에 떨어뜨려야 reorder가 적용됐고, 항목 위에 떨어뜨리면 아무 일도 안 일어났음. "안 되는데?" 피드백이 반복됨.
2. **Microsoft Edge / Chrome이 HTML5 draggable 요소가 in-flight이면 페이지가 막을 수 없는 네이티브 "분할 뷰 만들기" 드롭 오버레이를 화면 좌측에 띄웠다.** 워크스페이스를 끌면 화면이 갑자기 좌/우 split 미리보기로 뒤덮였고, 거기에 드롭하면 브라우저가 탭/URL split을 기대해서 우리 reorder는 작동 안 함. UX 손상이 가장 컸음.

5/29 저녁 두 번에 걸쳐 (1) 드롭 타겟을 항목 절반으로 확장 → 정확도 부분 회복 → 하지만 Edge의 split-view 오버레이는 여전. 그래서 (2) HTML5 draggable을 완전히 들어내고 mousedown/mousemove 기반의 포인터 제스처로 통째로 교체. 5/30 두 번의 후속 커밋으로 사이드바 안의 다른 요소(`<a>` 텍스트 노드, SVG 로고 등)에서 브라우저가 native drag를 시작하지 못하도록 defence-in-depth 가드까지 추가.

### 주요 변경

#### 1. 드롭 타겟을 갭 → 항목 절반 단위로 확장 (`37c6e53`)

- **`src/client/src/components/Sidebar.tsx`**: 8px gap-only 드롭 존을 제거하고, 항목 자체에 `onDragOver`를 부착. `e.clientY`가 항목 rect의 top/bottom 어느 절반에 있는지에 따라 gap index를 산출 (top half → 이 항목 앞, bottom half → 이 항목 뒤). 마지막 항목의 bottom half는 리스트 끝(`projects.length`)에 매핑.
- 드롭 인디케이터를 호버된 항목 위(또는 마지막 항목 아래)에 absolute 라인으로 렌더. 리스트 컨테이너 레벨의 `onDragLeave`가 `relatedTarget`이 외부로 빠질 때만 인디케이터를 끄게 해서, 항목 간 이동 시 깜빡임 제거.

#### 2. HTML5 drag → 포인터 기반 제스처로 통째 교체 (`cdccf7f`)

Edge / Chrome의 네이티브 split-view 드롭 오버레이는 HTML5 drag-and-drop이 in-flight일 때만 발생하므로, 그 메커니즘 자체를 들어내는 것 외엔 해결 방법이 없음.

- **`src/client/src/components/Sidebar.tsx`**: 항목의 `draggable` / `onDragStart` / `onDragEnd` / `onDragOver` / `onDrop`을 전부 제거. 대신 `onMouseDown`에서 window-level `mousemove` / `mouseup` / `keydown` 리스너를 attach.
- 6px 이동 threshold로 클릭 vs 드래그 구분 — threshold 미만이면 일반 클릭(네비게이션)으로 통과.
- mousemove 시 `document.querySelectorAll('[data-workspace-item]')`로 모든 항목 rect를 가져와 커서 Y → gap index 매핑 (리스트 위쪽 영역 → 0, 마지막 항목 아래쪽 → `projects.length`, 그 외 → 각 항목의 top/bottom 절반).
- mouseup 후 합성 click이 발사되는 순간을 잡으려고 `dragJustHappenedRef`에 플래그. Link `onClick`에서 이 ref가 true면 `e.preventDefault()`로 네비게이션 차단. `requestAnimationFrame`으로 다음 페인트에 false로 리셋해서 다음 정상 클릭은 통과.
- `Escape` keydown으로 진행 중인 reorder 캔슬.
- 드래그 중 cursor를 `grabbing`으로 변경, source 항목은 opacity 0.5로 흐려짐.

#### 3. 네이티브 drag 시작 자체 차단 (`8495531`, `41c8f07`)

(2) 적용 후에도 일부 케이스에서 Edge의 split-view 오버레이가 여전히 떴음 — 원인은 React Router `<Link>`가 렌더하는 `<a>` 태그가 기본적으로 draggable이라, mousedown 후 드래그가 시작되면 브라우저가 native 링크 드래그를 자체적으로 띄움. 사이드바 안의 SVG 로고나 텍스트 노드도 동일.

- **`8495531`**: 각 프로젝트 `<Link>`에 `draggable={false}` + `onDragStart={(e) => e.preventDefault()}` 명시. 활성/비활성 양쪽 style에 `userSelect: 'none'` 부착 — 드래그 중 텍스트 선택 모드로 빠지면 선택된 텍스트가 다시 draggable이 되는 우회 경로 차단.
- **`41c8f07`**: `handleItemMouseDown` 진입부에서 `e.preventDefault()` 호출 — mousedown 시점에 브라우저의 default "selection-then-drag / 네이티브 link drag / focus-with-selection" 동작을 다 끄지만, click 이벤트는 별도라 일반 클릭은 그대로 동작. 추가 defence-in-depth로 사이드바 최상위 컨테이너에 `onDragStart={(e) => e.preventDefault()}` — 사이드바 트리 안 어디서든 dragstart 이벤트가 버블 올라오면 무조건 캔슬.

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/client/src/components/Sidebar.tsx` | HTML5 drag 전체 제거 → mousedown 기반 reorder + draggable=false + 다중 dragStart 차단 + cursor: grabbing |

### 아키텍처 결정

1. **HTML5 drag-and-drop은 사이드바 reorder에서 완전 폐기**: Edge / Chrome의 네이티브 split-view 드롭 오버레이는 페이지가 끌 수 없는 user-agent UI. 그게 한 번이라도 떠서 사용자를 헷갈리게 할 수 있다면 메커니즘 자체를 안 쓰는 게 정답. 같은 결정을 사이드바 전체에 적용 (defence-in-depth `onDragStart` preventDefault).
2. **합성 click 가드는 ref + requestAnimationFrame**: mouseup이 발사되면 같은 mark에서 click 이벤트가 동기로 따라오므로, ref에 플래그를 세팅하고 Link onClick에서 검사한 뒤 다음 페인트에 리셋. setState는 비동기라 같은 tick에서 못 보기 때문에 ref가 필수.
3. **포인터 제스처는 항목 절반 단위, 갭 단위 아님**: 드롭 정확도가 일관되게 항목 한 줄 위로 표현돼서 픽셀 단위 정밀도 없이도 의도가 표현됨. 갭 단위 인디케이터 라인은 시각 표현용으로 유지하되 드롭 결정은 절반 단위.
4. **Escape로 캔슬**: drag 시작 후 사용자가 마음 바뀌면 마우스 떼지 않고도 빠져나갈 수 있어야 함 — 진행 중 ref / state 정리 + dragJustHappenedRef도 함께 세팅해서 mouseup이 나중에 발사되더라도 의도치 않은 reorder를 피함.
5. **다중 모듈 dragStart preventDefault는 과해 보이지만 의도된 belt-and-suspenders**: `<a>` draggable=false / mousedown preventDefault / 컨테이너 onDragStart — 어느 한 레이어가 미래의 리팩토링에서 깨져도 다른 레이어가 받침. Edge UX 손상은 한 번이라도 발생하면 신뢰가 깨지는 종류라 비용/효과가 맞음.
