# 전략편집기 — 전략지표 드래그 문제 (macOS Desktop) > **상태:** 2026-06 macOS Desktop(Tauri/WKWebView)에서 드래그·START 연결점 preview·드롭 연결 문제 **해결됨** > **목적:** 동일 증상 재발 시 원인 추적·수정 시 되돌리면 안 되는 설계 원칙을 기록 관련 문서: [전략편집기 로직 및 동작흐름.md](./전략편집기%20로직%20및%20동작흐름.md) --- ## 1. 증상 요약 ### 1.1 기대 동작 (웹 frontend와 동일) **그래프 모드 (전략 빌더):** 1. 우측 패널 **지표/논리 카드**를 눌러 드래그 2. 캔버스 **START** 노드 위에 올리면 **노란 테두리(glow)** + **연결점(handle) 활성화** 3. 드롭 시 START(또는 다른 부모 노드)에 **트리 연결·추가** **목록 모드:** - START 아래 점선 영역 highlight + 드롭 시 목록에 추가 ### 1.2 재발했던 실패 패턴 (반복 수정의 함정) | 단계 | 사용자 체감 | 실제로 깨진 것 | |------|-------------|----------------| | A | 드래그는 되나 START 위 반응 없음 | preview hit-test (좌표 0,0) | | B | A 수정 후 | **드래그 자체** 안 됨 | | C | B 수정 후 | 다시 **연결점/preview** 안 됨 | **핵심:** 드래그 UI와 preview/drop을 **서로 다른 메커니즘(HTML5 ↔ pointer)** 으로 번갈아 쓰면 한쪽만 고쳐지고 다른 쪽이 깨지는 **oscillation(진동)** 이 반복된다. --- ## 2. 환경 | 항목 | 값 | |------|-----| | 플랫폼 | macOS Desktop — Tauri + **WKWebView** | | 테스트 기기 | MacBook Pro M1 Max 등 | | 빌드 | `./scripts/build-desktop.sh --mac` | | 산출물 | `desktop/src-tauri/target/release/bundle/macos/GoldenChart.app` | | 웹 검증 | Docker frontend만으로 Desktop DnD 검증 **불충분** — 반드시 `.app` 실행 | --- ## 3. 아키텍처 — 플랫폼별 DnD 분기 분기 기준: `needsPointerPaletteDrag()` (`frontend/src/utils/paletteDragSession.ts`) ```typescript export function needsPointerPaletteDrag(): boolean { return isDesktop() || hasCoarsePointer(); } ``` | 플랫폼 | `needsPointerPaletteDrag()` | 드래그 방식 | 카드 `draggable` | |--------|----------------------------|-------------|------------------| | **Web 브라우저** | `false` | HTML5 DnD | `true` | | **Desktop (mac/Windows)** | `true` | **pointer overlay** | `false` | | **Coarse touch** | `true` | pointer overlay | `false` | `isDesktop()` — `window.__TAURI__` 또는 `html.desktop-client` 클래스 (`frontend/src/utils/platform.ts`) --- ## 4. 근본 원인 ### 4.1 macOS WKWebView + HTML5 DnD 는 preview/drop에 부적합 Desktop에서 HTML5(`draggable=true`)를 쓰면 **드래그 고스트는 보일 수 있으나**, WKWebView 네이티브 drag 세션 중: - `pointermove` / `mousemove` **중단** - `dragover` / `drag` 이벤트의 `clientX`, `clientY`가 **0, 0** 으로 오는 경우 다수 - `pageX`/`pageY`, 전역 mouse 추적 fallback도 drag 중에는 갱신되지 않음 preview/drop은 `resolvePaletteDropClientPoint()` → START DOM/flow hit-test에 **실제 커서 좌표**가 필요하다. 좌표가 팔레트 패널 위치에 고정되면 `anchorId`가 항상 `null` → **연결점·glow 없음**, 드롭도 실패. **웹 Chrome/Safari**는 HTML5 dragover에 유효 좌표를 주므로 동일 코드가 웹에서는 정상 동작한다. **mac Desktop만 HTML5를 쓰면 안 된다.** ### 4.2 pointer + window 리스너 + setPointerCapture + threshold (초기 Desktop 시도) 우측 지표 패널 구조: ```css .se-right-body { overflow-y: auto; } /* 스크롤 컨테이너 */ .se-palette-section--scroll { overflow-y: auto; } ``` 실패 요인: 1. **`setPointerCapture`를 팔레트 카드(스크롤 내부)에 설정** → WebKit이 제스처를 스크롤/캡처 대상으로 처리, `window`의 `pointermove` 미수신 2. **이동 threshold(3~6px) 이후에만 overlay 마운트** → move 이벤트가 오지 않으면 드래그가 **시작조차 안 됨** 3. **`bindWindowDrag`만 사용** → capture 단계에서 document까지 이벤트가 가기 전에 스크롤 패널이 흡수 팝업 패널 타이틀바 드래그(`useDraggablePanel`)는 스크롤 밖 fixed 영역이라 동일 패턴이 **거기서는** 동작했다. 팔레트 카드는 **다른 DOM 맥락**이다. ### 4.3 드래그와 preview를 다른 파이프로 처리 (oscillation) 잘못된 수정 패턴: - mac만 HTML5 → preview용 pointer/html rAF 추가 - preview 안 되면 mac만 pointer → drag 시작 로직 변경 - drag 안 되면 다시 HTML5 … **올바른 원칙:** Desktop에서 **drag 이동 좌표 = preview 좌표 = drop 좌표** — 단일 `subscribePaletteDrag` 파이프. --- ## 5. 최종 해결책 (현재 코드) ### 5.1 설계 원칙 1. **Desktop: HTML5 사용 금지** (`draggable={false}`, `onDragStart`에서 `preventDefault`) 2. **pointerdown 즉시 overlay** — threshold로 “드래그 시작”을 미루지 않음 3. **`document` capture `pointermove`/`pointerup`** — `bindDocumentPointerDrag()` (`frontend/src/utils/pointerDrag.ts`) 4. **`setPointerCapture` 사용 안 함** (카드·overlay 모두) 5. **이동·preview·drop** 모두 `subscribePaletteDrag` → 동일 `(x, y)` 6. **5px 미만 이동 = cancel** — 클릭(카드 선택)과 드래그 구분 (`CLICK_VS_DRAG_PX`) ### 5.2 Desktop 데이터 흐름 ``` PaletteChip.onPointerDownCapture → handlePalettePointerDown → startPaletteDragGesture ├─ mountDragOverlay (즉시) ├─ body.se-palette-drag-armed (스크롤 잠금) └─ bindDocumentPointerDrag(pointerId) ├─ move → notifyPaletteDragMove → emit('move') └─ up → finishDrag → setPaletteDragDropHandler → applyPaletteDropAt subscribePaletteDrag('move') [StrategyEditorCanvas, StrategyListEditor] → resolvePaletteDropClientPoint → updateDropPreview (dragOver, activeSourceSide) → FlowNodes START: se-flow-node--drop, se-flow-handle--active ``` ### 5.3 Web 데이터 흐름 (참고 — Desktop과 분리 유지) ``` PaletteChip.onDragStart → writePaletteHtmlDragData → HTML5 drag + window dragover → subscribePaletteDrag('move') 또는 rAF + getLastHtmlPaletteDragClientXY → setHtmlPaletteDropRouter → applyPaletteDropAt ``` --- ## 6. 핵심 파일 | 파일 | 역할 | |------|------| | `frontend/src/utils/paletteDragSession.ts` | Desktop/Web 분기, drag 세션, `subscribePaletteDrag`, drop handler | | `frontend/src/utils/pointerDrag.ts` | **`bindDocumentPointerDrag`** (Desktop 전용), `bindWindowDrag` (Web 보조) | | `frontend/src/utils/flowScreenProjection.ts` | client → flow 좌표, START hit-test (DOM rect 우선) | | `frontend/src/utils/platform.ts` | `isDesktop()`, `isMacDesktop()` | | `frontend/src/components/strategyEditor/PaletteChip.tsx` | **`onPointerDownCapture`**, `draggable={!pointerMode}` | | `frontend/src/components/strategyEditor/PaletteDragOverlay.tsx` | 드래그 중 overlay UI | | `frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx` | `setPaletteDragDropHandler`, `subscribePaletteDrag` → preview | | `frontend/src/components/strategyEditor/FlowNodes.tsx` | START `dragOver` / `activeSourceSide` UI | | `frontend/src/components/StrategyEditorPage.tsx` | `{needsPointerPaletteDrag() && }` | | `frontend/src/styles/strategyEditor.css` | `.se-palette-drag-armed` 스크롤 잠금, `.se-flow-node--drop` | --- ## 7. preview (START glow / 연결점) 구현 요약 `StrategyEditorCanvas.updateDropPreview(anchorId, flowPos)`: - `setNodes`로 해당 노드 `data.dragOver = true`, `data.activeSourceSide` 설정 - `FlowNodes.StartNode`: `se-flow-node--drop`, handle `se-flow-handle--active` 드래그 중 layout rebuild가 preview를 지우지 않도록: ```typescript // StrategyEditorCanvas rebuild effect if (isPaletteDragSessionActive()) return; ``` `isPaletteDragSessionActive()` = `activePayload != null || htmlPaletteDragActive` --- ## 8. hit-test (START 감지) `resolvePaletteDropClientPoint()` (`flowScreenProjection.ts`) 우선순위: 1. **`.react-flow__node[data-id]` DOM `getBoundingClientRect`** (pointer overlay에서 가장 정확) 2. flow 좌표 → screen rect 변환 (`projectFlowToScreen`, host: `.react-flow__viewport`) 3. `findFlowNodeIdAtClientPoint` 4. flow 좌표 hit (`findNodeAtFlowPosition`) Tauri에서 `screenToFlowPosition`이 screen px를 그대로 반환하는 경우가 있어 **manualProject** fallback 존재. --- ## 9. 재발 방지 — 하지 말아야 할 수정 | 금지 | 이유 | |------|------| | mac Desktop만 HTML5로 되돌리기 | WKWebView dragover 좌표 0,0 → preview/drop 불가 | | mac / Windows DnD 방식 분리 (mac HTML5, Win pointer) | oscilation 재발 | | Desktop에서 `draggable=true` | HTML5와 pointer 이중 경로 충돌 | | 팔레트 카드에 `setPointerCapture` | 스크롤 패널 내부에서 move 유실 | | threshold 큰 값(6px+)만 overlay 표시 | move 미수신 시 드래그 “안 됨” | | preview만 HTML5 `dragover`, drop만 pointer 등 **좌표 소스 이원화** | 한쪽만 동작 | | `document dragend`에서 무조건 `clearDropPreview()` | pointer 세션과 무관하게 preview 제거 가능 — HTML5일 때만 처리 | --- ## 10. 재발 시 진단 체크리스트 ### 10.1 드래그 자체가 안 될 때 - [ ] Desktop에서 카드 `draggable`이 `false`인지 (`needsPointerPaletteDrag()` true?) - [ ] `onPointerDownCapture` → `handlePalettePointerDown` 호출되는지 - [ ] `PaletteDragOverlay` 마운트 여부 (`StrategyEditorPage`) - [ ] pointerdown 직후 overlay 라벨 표시되는지 (HTML5 고스트와 구분) - [ ] `.se-right-body`에 `body.se-palette-drag-armed` 스크롤 잠금 적용되는지 - [ ] 기간 입력란 클릭 시 early return — 카드 **제목 영역**으로 테스트 - [ ] `./scripts/build-desktop.sh --mac` 후 **.app** 실행 (웹 dev server만 X) ### 10.2 드래그는 되나 START glow/연결점 없을 때 - [ ] `subscribePaletteDrag` move 이벤트 발생하는지 - [ ] `resolvePaletteDropClientPoint`가 `anchorId: '__strategy_start__'` 반환하는지 - [ ] `isPaletteDragSessionActive()` 중 rebuild effect가 preview를 지우지 않는지 - [ ] Desktop에서 HTML5 경로(`htmlPaletteDragActive`)로 잘못 들어가지 않는지 ### 10.3 드롭이 연결되지 않을 때 - [ ] Desktop: `setPaletteDragDropHandler` 등록 (`needsPointerPaletteDrag()` true) - [ ] Web: `setHtmlPaletteDropRouter` 등록 (`needsPointerPaletteDrag()` false) - [ ] `applyPaletteDropAt` → `canAcceptPaletteAsParent(START)` true --- ## 11. 검증 시나리오 1. **그래프 모드:** CCI(등) → START 위 glow + 연결점 → 드롭 연결 2. **연속 2~3회** 드래그 반복 3. **카드 살짝 클릭** → 선택만 되고 드래그/overlay 없음 (< 5px) 4. **목록 모드:** 점선 영역 highlight + 드롭 5. **Windows Desktop** 동일 pointer 경로 회귀 없음 6. **Web 브라우저** HTML5 드래그·preview 정상 --- ## 12. 참고 — oscillation 타임라인 (교훈) ``` HTML5(mac) → drag OK, preview FAIL (WKWebView coords) pointer+threshold → drag FAIL (scroll + capture) HTML5 + coord fix → drag OK, preview FAIL (근본 한계) mac HTML5 only → drag OK, preview FAIL pointer unified → drag OK, preview OK ← 최종 ``` **교훈:** Desktop WKWebView에서는 “드래그만 HTML5”와 “preview만 pointer” 조합이 아니라, **처음부터 끝까지 pointer overlay 단일 세션**이어야 한다. --- ## 13. 빌드·배포 ```bash # macOS Desktop (로컬) ./scripts/build-desktop.sh --mac # Windows 크로스는 cargo-xwin 필요 — mac 빌드와 별도 ./scripts/build-desktop.sh ``` Desktop 버전 bump 시 `desktop/src-tauri/tauri.conf.json` 등 반영 후 배포. --- ## 14. 변경 이력 | 날짜 | 내용 | |------|------| | 2026-06 | macOS Desktop 지표 드래그·START preview·드롭 통합 해결. `bindDocumentPointerDrag`, pointerdown 즉시 overlay, HTML5 Desktop 제거 | --- *문서 작성: 전략편집기 DnD 디버깅 세션 정리. 코드 변경 시 이 문서의 §5·§9도 함께 갱신할 것.*