diff --git a/desktop/package.json b/desktop/package.json index c8f649c..88c3112 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@goldenchart/desktop", - "version": "0.1.43", + "version": "0.1.48", "private": true, "type": "module", "scripts": { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b129fda..85645a1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "goldenchart-desktop" -version = "0.1.43" +version = "0.1.48" dependencies = [ "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 713a3b8..f5f678b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goldenchart-desktop" -version = "0.1.43" +version = "0.1.48" description = "GoldenChart Desktop Client" authors = ["GoldenChart"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 6cf62d9..0c8e56e 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "GoldenChart", - "version": "0.1.43", + "version": "0.1.48", "identifier": "com.goldenchart.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -14,7 +14,7 @@ "windows": [ { "label": "main", - "title": "GoldenChart (v0.1.43)", + "title": "GoldenChart (v0.1.48)", "width": 1440, "height": 900, "minWidth": 960, @@ -54,7 +54,7 @@ "endpoints": [ "https://exdev.co.kr/desktop/updates/latest.json" ], - "pubkey": "REPLACE_WITH_TAURI_SIGNER_PUBKEY" + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg3MUJCMjIyRkIyNzdFMUUKUldRZWZpZjdJckliaDlMS3pvRHpmQ2lnSFNjaDQrT1pEZTdDZEJ5S2RhOXV5aU1QY3JObEEwaFUK" } } } diff --git a/desktop/src/bridge/updater.ts b/desktop/src/bridge/updater.ts index 84274bd..9b3d568 100644 --- a/desktop/src/bridge/updater.ts +++ b/desktop/src/bridge/updater.ts @@ -146,9 +146,9 @@ async function formatUpdateError(e: unknown): Promise { } if (/invalid symbol|offset \d+|replace_with/.test(lower)) { if (onWin) { - return '이 버전(v0.1.1 이전/초기 exe)에는 자동 업데이트 키가 없습니다. 웹 PC 프로그램 탭에서 최신 exe 설치 파일을 받아 다시 설치해 주세요. 이후부터는 자동 업데이트가 됩니다.'; + return '이 PC에 updater 공개키가 포함되지 않은 빌드입니다(로컬 빌드 시 키 미적용). 웹 PC 프로그램 탭에서 최신 exe를 받아 다시 설치하면 이후 자동 업데이트가 됩니다.'; } - return '이 버전(v0.1.1 이전/초기 dmg)에는 자동 업데이트 키가 없습니다. 웹 PC 프로그램 탭에서 최신 dmg를 받아 Applications에 다시 설치해 주세요. 이후부터는 자동 업데이트가 됩니다.'; + return '이 PC에 updater 공개키가 포함되지 않은 빌드입니다(로컬 build-desktop.sh 시 키 미적용). 웹 PC 프로그램 탭에서 최신 dmg를 Applications에 설치하면 이후 자동 업데이트가 됩니다.'; } if (/404|not found/.test(lower)) { return onMac diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index dbbf501..2bea34c 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -69,17 +69,17 @@ export default function PaletteChip({ const onPointerDown = (e: React.PointerEvent) => { if (!pointerMode) return; - handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); + handlePalettePointerDown(e, buildPayload(), e.currentTarget as HTMLElement); }; const onMouseDown = (e: React.MouseEvent) => { if (!pointerMode) return; - handlePaletteMouseDown(e, buildPayload(), composite ? `${label} + ${label}` : label); + handlePaletteMouseDown(e, buildPayload(), e.currentTarget as HTMLElement); }; const onTouchStart = (e: React.TouchEvent) => { if (!pointerMode) return; - handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label); + handlePaletteTouchStart(e, buildPayload(), e.currentTarget as HTMLElement); }; const handleClick = () => { diff --git a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx index 9cac6d1..f73c7d3 100644 --- a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx +++ b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { bindPaletteDragOverlayElement, @@ -11,16 +11,60 @@ import { } from '../../utils/paletteDragSession'; import { clientXYFromReact } from '../../utils/pointerDrag'; +/** body portal 에서도 우측 패널과 동일한 palette 테마 토큰 적용 */ +function themeScopeClassName(source: HTMLElement): string { + const page = source.closest('.se-page'); + if (!page) return 'se-page'; + const parts = ['se-page']; + page.classList.forEach(cls => { + if (cls.startsWith('se-page--')) parts.push(cls); + }); + return parts.join(' '); +} + +function mountCardClone(host: HTMLElement, source: HTMLElement): void { + host.innerHTML = ''; + const rect = source.getBoundingClientRect(); + const clone = source.cloneNode(true) as HTMLElement; + clone.classList.remove('se-palette-card--pointer'); + clone.classList.add('se-palette-card--dragging'); + clone.setAttribute('aria-hidden', 'true'); + clone.style.width = `${rect.width}px`; + clone.style.boxSizing = 'border-box'; + + const themeScope = document.createElement('div'); + themeScope.className = `se-palette-drag-overlay-theme ${themeScopeClassName(source)}`; + themeScope.appendChild(clone); + host.appendChild(themeScope); +} + +function positionGhostHost( + host: HTMLElement, + x: number, + y: number, + grabOffsetX: number, + grabOffsetY: number, +): void { + host.style.left = `${x - grabOffsetX}px`; + host.style.top = `${y - grabOffsetY}px`; +} + /** - * pointer/touch 드래그 overlay (sessionRef 로 stale closure 방지, window touch 백업과 연동) + * pointer/touch 드래그 overlay — 원본 카드 DOM 클론으로 web HTML5 drag ghost 와 동일 UI */ export default function PaletteDragOverlay() { const [session, setSession] = useState(null); const sessionRef = useRef(null); - const labelRef = useRef(null); + const ghostHostRef = useRef(null); sessionRef.current = session; + const positionFromSession = useCallback((s: PaletteDragOverlaySession) => { + const host = ghostHostRef.current; + if (!host) return; + positionGhostHost(host, s.x, s.y, s.grabOffsetX, s.grabOffsetY); + }, []); + useEffect(() => { registerPaletteDragOverlay({ mount: next => { @@ -37,27 +81,27 @@ export default function PaletteDragOverlay() { return () => registerPaletteDragOverlay(null); }, []); + useLayoutEffect(() => { + if (!session?.sourceElement || !ghostHostRef.current) return; + mountCardClone(ghostHostRef.current, session.sourceElement); + positionFromSession(session); + }, [session, positionFromSession]); + useEffect(() => { return subscribePaletteDrag(ev => { if (ev.phase !== 'move' && ev.phase !== 'start') return; - const label = labelRef.current; - if (!label) return; - label.style.left = `${ev.x}px`; - label.style.top = `${ev.y}px`; + const s = sessionRef.current; + const host = ghostHostRef.current; + if (!s || !host) return; + positionGhostHost(host, ev.x, ev.y, s.grabOffsetX, s.grabOffsetY); }); }, []); const overlayRefCallback = useCallback((el: HTMLDivElement | null) => { bindPaletteDragOverlayElement(el); const s = sessionRef.current; - if (el && s) { - const label = labelRef.current; - if (label) { - label.style.left = `${s.x}px`; - label.style.top = `${s.y}px`; - } - } - }, []); + if (el && s) positionFromSession(s); + }, [positionFromSession]); const onPointerMove = useCallback((e: React.PointerEvent) => { const s = sessionRef.current; @@ -118,12 +162,13 @@ export default function PaletteDragOverlay() { onTouchCancel={onTouchCancel} >
- {session.payload.label} -
+ ref={ghostHostRef} + className="se-palette-drag-overlay-ghost" + style={{ + left: session.x - session.grabOffsetX, + top: session.y - session.grabOffsetY, + }} + /> , document.body, ); diff --git a/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx b/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx index 8328292..8c81605 100644 --- a/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx +++ b/frontend/src/components/strategyEditor/SidewaysFilterPaletteTab.tsx @@ -106,7 +106,7 @@ function SidewaysFilterChip({ value: item.id, label: item.label, }; - handlePalettePointerDown(e, payload, item.label); + handlePalettePointerDown(e, payload, e.currentTarget as HTMLElement); }; const onMouseDown = (e: React.MouseEvent) => { @@ -116,7 +116,7 @@ function SidewaysFilterChip({ value: item.id, label: item.label, }; - handlePaletteMouseDown(e, payload, item.label); + handlePaletteMouseDown(e, payload, e.currentTarget as HTMLElement); }; const onTouchStart = (e: React.TouchEvent) => { @@ -126,7 +126,7 @@ function SidewaysFilterChip({ value: item.id, label: item.label, }; - handlePaletteTouchStart(e, payload, item.label); + handlePaletteTouchStart(e, payload, e.currentTarget as HTMLElement); }; const handleClick = () => onSelect(); diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 3dc1018..55b2739 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -2071,6 +2071,37 @@ body.se-palette-drag-armed .se-palette-section--scroll { background: transparent; } +.se-palette-drag-overlay-ghost { + position: fixed; + z-index: 20041; + pointer-events: none; +} + +/* body portal — .se-page 테마 토큰(scope)만 카드 클론에 전달 */ +.se-palette-drag-overlay-theme.se-page { + display: inline-block; + flex: none; + width: auto; + height: auto; + min-height: 0; + overflow: visible; + position: static; + background: transparent; + color: var(--se-text); +} + +.se-palette-drag-overlay-ghost .se-palette-card { + cursor: grabbing; + transform: none; + margin: 0; + opacity: 1; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.42); +} + +.se-palette-drag-overlay-ghost .se-palette-card--dragging:hover { + transform: none; +} + .se-palette-drag-overlay-label { position: fixed; z-index: 20041; diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index f362d15..6bfdb90 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -34,6 +34,10 @@ export interface PaletteDragOverlaySession { pointerId: number; x: number; y: number; + /** 드래그 시작 카드 — overlay 에 동일 UI 클론용 */ + sourceElement: HTMLElement | null; + grabOffsetX: number; + grabOffsetY: number; } type PaletteDragListener = (event: PaletteDragEvent) => void; @@ -183,17 +187,37 @@ function mountDragOverlay( payload: PaletteDragPayload, xy: { x: number; y: number }, pointerId: number, + sourceElement: HTMLElement | null, + grabOffset: { x: number; y: number }, ) { activePayload = payload; activePointerId = pointerId; lastDragClientXY = { x: xy.x, y: xy.y }; document.body.classList.add('se-palette-drag-active'); armPaletteDragScrollLock(); - overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y }); + overlayController?.mount({ + payload, + pointerId, + x: xy.x, + y: xy.y, + sourceElement, + grabOffsetX: grabOffset.x, + grabOffsetY: grabOffset.y, + }); emit({ phase: 'start', x: xy.x, y: xy.y, payload }); notifyPaletteDragMove(xy.x, xy.y); } +function grabOffsetFromSource( + sourceElement: HTMLElement | null, + clientX: number, + clientY: number, +): { x: number; y: number } { + if (!sourceElement) return { x: 0, y: 0 }; + const rect = sourceElement.getBoundingClientRect(); + return { x: clientX - rect.left, y: clientY - rect.top }; +} + export function notifyPaletteDragMove(clientX: number, clientY: number) { if (!activePayload) return; lastDragClientXY = { x: clientX, y: clientY }; @@ -232,12 +256,14 @@ function startPaletteDragGesture( startY: number, pointerId: number, payload: PaletteDragPayload, + sourceElement: HTMLElement | null, ): void { if (activePayload) cleanupDrag(); unbindGestureListeners(); const origin = { x: startX, y: startY }; - mountDragOverlay(payload, origin, pointerId); + const grabOffset = grabOffsetFromSource(sourceElement, startX, startY); + mountDragOverlay(payload, origin, pointerId, sourceElement, grabOffset); unbindGesture = bindDocumentPointerDrag( pointerId, @@ -257,7 +283,7 @@ function startPaletteDragGesture( export function handlePalettePointerDown( e: React.PointerEvent, payload: PaletteDragPayload, - _label: string, + sourceElement: HTMLElement, ): void { if (!needsPointerPaletteDrag()) return; if (!isPrimaryPointerButton(e)) return; @@ -268,13 +294,13 @@ export function handlePalettePointerDown( e.preventDefault(); - startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload); + startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload, sourceElement); } export function handlePaletteMouseDown( e: React.MouseEvent, payload: PaletteDragPayload, - _label: string, + sourceElement: HTMLElement, ): void { if (!needsPointerPaletteDrag()) return; if (e.button !== 0) return; @@ -285,13 +311,13 @@ export function handlePaletteMouseDown( if (typeof window !== 'undefined' && 'PointerEvent' in window) return; e.preventDefault(); - startPaletteDragGesture(e.clientX, e.clientY, 1, payload); + startPaletteDragGesture(e.clientX, e.clientY, 1, payload, sourceElement); } export function handlePaletteTouchStart( e: React.TouchEvent, payload: PaletteDragPayload, - _label: string, + sourceElement: HTMLElement, ): void { if (!needsPointerPaletteDrag()) return; if (typeof window !== 'undefined' && 'PointerEvent' in window) return; @@ -300,7 +326,7 @@ export function handlePaletteTouchStart( e.preventDefault(); const touch = e.touches[0]; - startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload); + startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload, sourceElement); } /* ── Web HTML5 ── */ diff --git a/package-lock.json b/package-lock.json index b2bcce7..e50c402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ }, "desktop": { "name": "@goldenchart/desktop", - "version": "0.1.43", + "version": "0.1.48", "dependencies": { "@goldenchart/shared": "*", "@stomp/stompjs": "^7.3.0", diff --git a/scripts/prepare-tauri-build.sh b/scripts/prepare-tauri-build.sh index fd451b3..1736a0e 100755 --- a/scripts/prepare-tauri-build.sh +++ b/scripts/prepare-tauri-build.sh @@ -7,12 +7,14 @@ CONF="$ROOT/desktop/src-tauri/tauri.conf.json" KEY_PATH="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" PUB_PATH="${TAURI_SIGNER_PUB:-$ROOT/desktop/updater.pub}" +# 공개키는 private key 없이도 앱에 포함해야 자동 업데이트 검증 가능 +if [[ -f "$PUB_PATH" && -x "$ROOT/scripts/patch-tauri-updater-pubkey.sh" ]]; then + "$ROOT/scripts/patch-tauri-updater-pubkey.sh" +fi + enable="false" if [[ -f "$KEY_PATH" && -f "$PUB_PATH" ]]; then enable="true" - if [[ -x "$ROOT/scripts/patch-tauri-updater-pubkey.sh" ]]; then - "$ROOT/scripts/patch-tauri-updater-pubkey.sh" - fi fi node -e " @@ -42,7 +44,19 @@ if [[ "$enable" == "true" ]]; then echo "[prepare-tauri-build] updater signing enabled" else unset TAURI_SIGNING_PRIVATE_KEY 2>/dev/null || true - echo "[prepare-tauri-build] updater signing skipped (no key)" + echo "[prepare-tauri-build] updater signing skipped (no private key at $KEY_PATH)" +fi + +# 빌드된 앱이 자동 업데이트 가능한지 pubkey 확인 +PUBKEY_CHECK="$(node -e " +const c=require(process.argv[1]); +const k=c.plugins?.updater?.pubkey||''; +process.exit(k && k!=='REPLACE_WITH_TAURI_SIGNER_PUBKEY' ? 0 : 1); +" "$CONF" 2>/dev/null && echo ok || echo fail)" +if [[ "$PUBKEY_CHECK" != "ok" ]]; then + echo "[prepare-tauri-build] ERROR: updater pubkey 미설정 — desktop/updater.pub 확인 후 patch-tauri-updater-pubkey.sh 실행" >&2 + echo " 이 상태로 빌드하면 앱 내 자동 업데이트가 동작하지 않습니다." >&2 + exit 1 fi # macOS: ad-hoc codesign by default (Apple Silicon requires signing). diff --git a/전략편집기 전략지표 드래그 문제.md b/전략편집기 전략지표 드래그 문제.md new file mode 100644 index 0000000..98d1372 --- /dev/null +++ b/전략편집기 전략지표 드래그 문제.md @@ -0,0 +1,287 @@ +# 전략편집기 — 전략지표 드래그 문제 (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도 함께 갱신할 것.*