From a3aca639c166735f02c6675de0799523ee47ca70 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 15 Jun 2026 09:39:19 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=A5=20os=20=EC=A0=84=EB=9E=B5=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EC=A7=80=ED=91=9C=20=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EA=B7=B8=20=EB=AC=B8=EC=A0=9C=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 4 +- .../components/strategyEditor/PaletteChip.tsx | 14 +- .../strategyEditor/PaletteDragOverlay.tsx | 5 - .../SidewaysFilterPaletteTab.tsx | 15 +- .../strategyEditor/StrategyEditorCanvas.tsx | 11 +- frontend/src/styles/strategyEditor.css | 12 + frontend/src/utils/flowScreenProjection.ts | 127 ++++++-- frontend/src/utils/paletteDragSession.ts | 289 ++++++++---------- frontend/src/utils/pointerDrag.ts | 33 ++ package-lock.json | 2 +- 13 files changed, 309 insertions(+), 209 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index ba37311..c8f649c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@goldenchart/desktop", - "version": "0.1.22", + "version": "0.1.43", "private": true, "type": "module", "scripts": { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 293a972..b129fda 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.0" +version = "0.1.43" dependencies = [ "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 30f1fb8..713a3b8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "goldenchart-desktop" -version = "0.1.0" +version = "0.1.43" 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 ddc071f..6cf62d9 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.22", + "version": "0.1.43", "identifier": "com.goldenchart.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -14,7 +14,7 @@ "windows": [ { "label": "main", - "title": "GoldenChart", + "title": "GoldenChart (v0.1.43)", "width": 1440, "height": 900, "minWidth": 960, diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index aba1de6..dbbf501 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -4,7 +4,6 @@ import { handlePalettePointerDown, handlePaletteTouchStart, endHtmlDragMouseTracking, - markPaletteHtmlDragEnd, needsPointerPaletteDrag, shouldSuppressPaletteClick, startHtmlDragMouseTracking, @@ -52,8 +51,10 @@ export default function PaletteChip({ longPeriod, }); + const pointerMode = needsPointerPaletteDrag(); + const onDragStart = (e: React.DragEvent) => { - if (needsPointerPaletteDrag()) { + if (pointerMode) { e.preventDefault(); return; } @@ -67,14 +68,17 @@ export default function PaletteChip({ }; const onPointerDown = (e: React.PointerEvent) => { + if (!pointerMode) return; handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); }; const onMouseDown = (e: React.MouseEvent) => { + if (!pointerMode) return; handlePaletteMouseDown(e, buildPayload(), composite ? `${label} + ${label}` : label); }; const onTouchStart = (e: React.TouchEvent) => { + if (!pointerMode) return; handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label); }; @@ -102,11 +106,11 @@ export default function PaletteChip({ return (
{ hideHint(); + if (!needsPointerPaletteDrag()) return; const payload: PaletteDragPayload = { type: 'sidewaysFilter', value: item.id, @@ -107,6 +109,16 @@ function SidewaysFilterChip({ handlePalettePointerDown(e, payload, item.label); }; + const onMouseDown = (e: React.MouseEvent) => { + hideHint(); + const payload: PaletteDragPayload = { + type: 'sidewaysFilter', + value: item.id, + label: item.label, + }; + handlePaletteMouseDown(e, payload, item.label); + }; + const onTouchStart = (e: React.TouchEvent) => { hideHint(); const payload: PaletteDragPayload = { @@ -136,7 +148,8 @@ function SidewaysFilterChip({ className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}${needsPointerPaletteDrag() ? ' se-palette-card--pointer' : ''}`} onDragStart={onDragStart} onDragEnd={onDragEnd} - onPointerDown={onPointerDown} + onPointerDownCapture={onPointerDown} + onMouseDown={onMouseDown} onTouchStart={onTouchStart} onClick={handleClick} onDoubleClick={handleDoubleClick} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 83f9594..b313da2 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -34,6 +34,7 @@ import { isPaletteHtmlDrag, isPaletteHtmlDragActive, getLastHtmlPaletteDragClientXY, + isPaletteDragSessionActive, markPaletteHtmlDragEnd, needsPointerPaletteDrag, readPaletteHtmlDragData, @@ -898,6 +899,7 @@ function StrategyEditorCanvasInner({ // 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지) useEffect(() => { + if (isPaletteDragSessionActive()) return; const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow(); const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current); pruneEdgeHandles(edgeHandlesRef.current, mergedEdges); @@ -1319,7 +1321,7 @@ function StrategyEditorCanvasInner({ return; } const xy = getLastHtmlPaletteDragClientXY(); - if (xy) { + if (xy && (xy.x !== 0 || xy.y !== 0)) { const { flowPos, anchorId } = resolveDropFromClientRef.current(xy.x, xy.y); if (anchorId) { updatePreviewRef.current(anchorId, flowPos); @@ -1342,7 +1344,7 @@ function StrategyEditorCanvasInner({ }, []); useEffect(() => { - if (needsPointerPaletteDrag()) return; + if (needsPointerPaletteDrag()) return undefined; const onDocDragOver = (e: DragEvent) => { if (!isPaletteHtmlDragActive()) return; e.preventDefault(); @@ -1352,12 +1354,13 @@ function StrategyEditorCanvasInner({ lastPreviewAnchorRef.current = anchorId; } }; - document.addEventListener('dragover', onDocDragOver, { passive: false }); - return () => document.removeEventListener('dragover', onDocDragOver); + document.addEventListener('dragover', onDocDragOver, { passive: false, capture: true }); + return () => document.removeEventListener('dragover', onDocDragOver, { capture: true }); }, []); useEffect(() => { const onDocDragEnd = () => { + if (!isPaletteHtmlDragActive()) return; lastPreviewAnchorRef.current = null; lastPreviewKeyRef.current = null; clearPreviewRef.current(); diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index b87f245..3dc1018 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -2050,6 +2050,18 @@ body.se-palette-drag-active { user-select: none; } +body.se-palette-drag-armed .se-right-body, +body.se-palette-drag-armed .se-palette-section--scroll { + overflow: hidden !important; + overscroll-behavior: none; + touch-action: none; +} + +.se-palette-card .se-palette-period-input[readonly] { + pointer-events: none; + cursor: inherit; +} + .se-palette-drag-overlay { position: fixed; inset: 0; diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts index d079f0c..feb2334 100644 --- a/frontend/src/utils/flowScreenProjection.ts +++ b/frontend/src/utils/flowScreenProjection.ts @@ -4,18 +4,27 @@ import { getLastHtmlPaletteDragClientXY, isPaletteHtmlDragActive, } from './paletteDragSession'; +import { getNodeDimensions } from './strategyFlowLayout'; type FlowProjector = Pick; const DRAG_LAYER_SELECTOR = '.se-palette-drag-overlay, .se-palette-drag-ghost'; +function getFlowProjectionHost(containerEl: HTMLElement | null): HTMLElement | null { + if (!containerEl) return null; + return containerEl.querySelector('.react-flow__viewport') as HTMLElement | null + ?? containerEl.querySelector('.react-flow__renderer') as HTMLElement | null + ?? containerEl.querySelector('.react-flow') as HTMLElement | null + ?? containerEl; +} + function manualProject( clientX: number, clientY: number, rf: FlowProjector, containerEl: HTMLElement | null, ): { x: number; y: number } { - const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? containerEl; + const host = getFlowProjectionHost(containerEl); if (!host) return { x: 0, y: 0 }; const rect = host.getBoundingClientRect(); @@ -159,12 +168,73 @@ export interface PaletteDropResolution { anchorId: string | null; } -/** WKWebView HTML5 drag — dragover client 좌표가 0,0 으로 오는 경우 보정 */ -export function resolvePaletteDragClientPoint(clientX: number, clientY: number): { x: number; y: number } { - if (clientX !== 0 || clientY !== 0) return { x: clientX, y: clientY }; - if (!isPaletteHtmlDragActive()) return { x: clientX, y: clientY }; - const last = getLastHtmlPaletteDragClientXY(); - return last ?? { x: clientX, y: clientY }; +function projectFlowToScreen( + flowX: number, + flowY: number, + rf: FlowProjector | null, + containerEl: HTMLElement | null, +): { x: number; y: number } { + const host = getFlowProjectionHost(containerEl); + if (!host || !rf) return { x: flowX, y: flowY }; + + const rect = host.getBoundingClientRect(); + const { x: vx, y: vy, zoom } = rf.getViewport(); + const z = zoom || 1; + return { + x: rect.left + vx + flowX * z, + y: rect.top + vy + flowY * z, + }; +} + +/** WKWebView HTML5 drag — dragover client 0,0 보정 (Web 전용) */ +export function resolvePaletteDragClientPoint( + clientX: number, + clientY: number, +): { x: number; y: number } { + if (clientX !== 0 || clientY !== 0) { + return { x: clientX, y: clientY }; + } + if (!isPaletteHtmlDragActive() && !getActivePaletteDrag()) { + return { x: clientX, y: clientY }; + } + const fromHtml = getLastHtmlPaletteDragClientXY(); + if (fromHtml && (fromHtml.x !== 0 || fromHtml.y !== 0)) return fromHtml; + return { x: clientX, y: clientY }; +} + +/** flow 노드 screen rect hit-test (DOM / elementsFromPoint 불필요 — WKWebView HTML5 drag 대응) */ +function findConnectTargetAtClientPoint( + clientX: number, + clientY: number, + rf: FlowProjector | null, + containerEl: HTMLElement | null, + nodes: { id: string; position: { x: number; y: number } }[], + isConnectTarget: (nodeId: string) => boolean, +): string | null { + const pad = 12; + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i]; + if (!isConnectTarget(n.id)) continue; + const dim = getNodeDimensions(n.id); + const tl = projectFlowToScreen(n.position.x, n.position.y, rf, containerEl); + const br = projectFlowToScreen( + n.position.x + dim.w, + n.position.y + dim.h, + rf, + containerEl, + ); + const left = Math.min(tl.x, br.x) - pad; + const right = Math.max(tl.x, br.x) + pad; + const top = Math.min(tl.y, br.y) - pad; + const bottom = Math.max(tl.y, br.y) + pad; + if ( + clientX >= left && clientX <= right + && clientY >= top && clientY <= bottom + ) { + return n.id; + } + } + return null; } /** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */ @@ -183,6 +253,32 @@ export function resolvePaletteDropClientPoint( const { x: px, y: py } = resolvePaletteDragClientPoint(clientX, clientY); const flowPos = projectScreenToFlowPosition(px, py, rf, containerEl); + // 1) DOM rect — pointer overlay 에서 가장 정확 + for (const node of nodes) { + if (!isConnectTarget(node.id)) continue; + const escaped = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(node.id) + : node.id.replace(/"/g, '\\"'); + const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`) + ?? document.getElementById(`react-flow__node-${node.id}`); + if (!el) continue; + const rect = el.getBoundingClientRect(); + const pad = 12; + if ( + px >= rect.left - pad && px <= rect.right + pad + && py >= rect.top - pad && py <= rect.bottom + pad + ) { + return { flowPos, anchorId: node.id }; + } + } + + const flowRectId = findConnectTargetAtClientPoint( + px, py, rf, containerEl, nodes, isConnectTarget, + ); + if (flowRectId) { + return { flowPos, anchorId: flowRectId }; + } + const domId = findFlowNodeIdAtClientPoint(px, py); if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) { return { flowPos, anchorId: domId }; @@ -195,23 +291,6 @@ export function resolvePaletteDropClientPoint( } } - for (const node of nodes) { - if (!isConnectTarget(node.id)) continue; - const escaped = typeof CSS !== 'undefined' && CSS.escape - ? CSS.escape(node.id) - : node.id.replace(/"/g, '\\"'); - const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`) - ?? document.getElementById(`react-flow__node-${node.id}`); - if (!el) continue; - const rect = el.getBoundingClientRect(); - if ( - px >= rect.left && px <= rect.right - && py >= rect.top && py <= rect.bottom - ) { - return { flowPos, anchorId: node.id }; - } - } - const flowHit = findAtFlow(flowPos, nodes); if (flowHit && isConnectTarget(flowHit.id)) { return { flowPos, anchorId: flowHit.id }; diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 070b4cd..f362d15 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,13 +1,18 @@ /** - * Desktop(Tauri) 및 터치 — HTML5 DnD 대신 pointer/touch + overlay 드래그 - * (useDraggablePanel 과 동일: pointerdown 1회 bindWindowDrag 로 전체 제스처 처리) + * Desktop(Tauri) — document capture pointer 드래그 + overlay + * Web — HTML5 DnD + * + * Desktop: pointerdown 즉시 overlay + document capture move/end + * → subscribePaletteDrag(move) → preview + drop (동일 좌표 파이프) + * Web: HTML5 drag + dragover 좌표 (브라우저 네이티브 지원) */ import { elementsUnderDragPoint } from './flowScreenProjection'; import { + bindDocumentPointerDrag, bindWindowDrag, isPrimaryPointerButton, } from './pointerDrag'; -import { isDesktop, isMacDesktop } from './platform'; +import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { type: string; @@ -40,20 +45,21 @@ type OverlayController = { bindElement: (el: HTMLElement | null) => void; }; -let suppressClickUntil = 0; -const DRAG_THRESHOLD_PX = 6; +/** 클릭과 드래그 구분 — 이보다 작으면 cancel (클릭 허용) */ +const CLICK_VS_DRAG_PX = 5; +let suppressClickUntil = 0; let activePayload: PaletteDragPayload | null = null; let activePointerId: number | null = null; let overlayController: OverlayController | null = null; let overlayElement: HTMLElement | null = null; -let gestureCaptureEl: HTMLElement | null = null; let listeners = new Set(); let dropHandler: PaletteDropHandler | null = null; let moveRafId: number | null = null; let pendingMove: { x: number; y: number } | null = null; let unbindGesture: (() => void) | null = null; let lastDragClientXY: { x: number; y: number } | null = null; + let htmlDragMouseTrackUnbind: (() => void) | null = null; let htmlPaletteDragActive = false; let lastHtmlDragClientXY: { x: number; y: number } | null = null; @@ -66,58 +72,12 @@ type HtmlPaletteDropRouter = ( ) => void; let htmlDropRouter: HtmlPaletteDropRouter | null = null; +let htmlDropCommitted = false; export function setHtmlPaletteDropRouter(router: HtmlPaletteDropRouter | null): void { htmlDropRouter = router; } -export function getActiveHtmlDragPayload(): PaletteDragPayload | null { - return activeHtmlDragPayload; -} - -function installHtmlPaletteDragGlobals() { - if (typeof window === 'undefined') return; - const w = window as Window & { __gcPaletteHtmlDragGlobals?: boolean }; - if (w.__gcPaletteHtmlDragGlobals) return; - w.__gcPaletteHtmlDragGlobals = true; - - window.addEventListener('dragover', (e) => { - if (!htmlPaletteDragActive) return; - if (e.clientX !== 0 || e.clientY !== 0) { - lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; - } - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; - }, { passive: false }); - - window.addEventListener('drag', (e) => { - if (!htmlPaletteDragActive) return; - if (e.clientX !== 0 || e.clientY !== 0) { - lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; - } - }); - - window.addEventListener('dragend', () => { - stopHtmlDragMouseTracking(); - htmlPaletteDragActive = false; - lastHtmlDragClientXY = null; - activeHtmlDragPayload = null; - }); - - document.addEventListener('drop', (e) => { - if (!htmlPaletteDragActive) return; - e.preventDefault(); - e.stopPropagation(); - const payload = readPaletteHtmlDragDataFromEvent(e) ?? activeHtmlDragPayload; - if (!payload || !htmlDropRouter) return; - const x = e.clientX !== 0 || e.clientY !== 0 ? e.clientX : (lastHtmlDragClientXY?.x ?? 0); - const y = e.clientX !== 0 || e.clientY !== 0 ? e.clientY : (lastHtmlDragClientXY?.y ?? 0); - htmlDropRouter(x, y, payload); - }, true); -} - -installHtmlPaletteDragGlobals(); - function hasCoarsePointer(): boolean { if (typeof window === 'undefined') return false; try { @@ -127,12 +87,15 @@ function hasCoarsePointer(): boolean { } } -/** HTML5 DnD 대신 pointer overlay (Windows Desktop 등). macOS WKWebView는 HTML5 + mousemove preview */ +/** Desktop(Tauri) + coarse touch — pointer overlay. Web browser — HTML5 */ export function needsPointerPaletteDrag(): boolean { - if (isMacDesktop()) return false; return isDesktop() || hasCoarsePointer(); } +export function isPaletteDragSessionActive(): boolean { + return activePayload != null || htmlPaletteDragActive; +} + export function subscribePaletteDrag(listener: PaletteDragListener): () => void { listeners.add(listener); return () => listeners.delete(listener); @@ -165,24 +128,8 @@ function emit(event: PaletteDragEvent) { } function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) { - const event: PaletteDragEvent = { phase: 'end', x: xy.x, y: xy.y, payload }; - dropHandler?.(event); - emit(event); -} - -function releasePointerCapture(pointerId: number | null) { - if (pointerId == null) return; - try { - gestureCaptureEl?.releasePointerCapture(pointerId); - } catch { - /* ignore */ - } - gestureCaptureEl = null; - try { - overlayElement?.releasePointerCapture(pointerId); - } catch { - /* ignore */ - } + dropHandler?.({ phase: 'end', x: xy.x, y: xy.y, payload }); + emit({ phase: 'end', x: xy.x, y: xy.y, payload }); } function unbindGestureListeners() { @@ -190,10 +137,16 @@ function unbindGestureListeners() { unbindGesture = null; } +function armPaletteDragScrollLock() { + document.body.classList.add('se-palette-drag-armed'); +} + +function releasePaletteDragScrollLock() { + document.body.classList.remove('se-palette-drag-armed'); +} + function cleanupDrag() { - const pointerId = activePointerId; unbindGestureListeners(); - releasePointerCapture(pointerId); activePayload = null; activePointerId = null; pendingMove = null; @@ -203,13 +156,17 @@ function cleanupDrag() { moveRafId = null; } document.body.classList.remove('se-palette-drag-active'); + releasePaletteDragScrollLock(); overlayController?.unmount(); overlayElement = null; - document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); } function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { - if (!activePayload) return; + if (!activePayload) { + unbindGestureListeners(); + releasePaletteDragScrollLock(); + return; + } const payload = activePayload; const dropXY = lastDragClientXY ?? xy; @@ -231,6 +188,7 @@ function mountDragOverlay( 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 }); emit({ phase: 'start', x: xy.x, y: xy.y, payload }); notifyPaletteDragMove(xy.x, xy.y); @@ -260,95 +218,40 @@ export function notifyPaletteDragCancel(clientX: number, clientY: number) { finishDrag('cancel', { x: clientX, y: clientY }); } -export function completePaletteDragAt(clientX: number, clientY: number): boolean { - if (!activePayload) return false; - finishDrag('end', { x: clientX, y: clientY }); - return true; -} - export function bindPaletteDragOverlayElement(el: HTMLElement | null) { overlayElement = el; overlayController?.bindElement(el); - if (el && activePointerId != null) { - try { - el.setPointerCapture(activePointerId); - } catch { - /* window gesture listener 가 move/end 처리 */ - } - } } +/** + * Desktop pointer 드래그 — pointerdown 즉시 overlay, document capture 로 move/end + * (스크롤 패널·setPointerCapture 충돌 없음 — drag + preview + drop 동일 좌표) + */ function startPaletteDragGesture( startX: number, startY: number, pointerId: number, payload: PaletteDragPayload, ): void { - if (activePayload) { - cleanupDrag(); - } + if (activePayload) cleanupDrag(); unbindGestureListeners(); - let dragging = false; + const origin = { x: startX, y: startY }; + mountDragOverlay(payload, origin, pointerId); - unbindGesture = bindWindowDrag( - (xy) => { - if (!dragging) { - const dx = xy.x - startX; - const dy = xy.y - startY; - if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; - dragging = true; - mountDragOverlay(payload, xy, pointerId); - return; - } - notifyPaletteDragMove(xy.x, xy.y); - }, + unbindGesture = bindDocumentPointerDrag( + pointerId, + (xy) => notifyPaletteDragMove(xy.x, xy.y), (xy) => { unbindGestureListeners(); - if (dragging && activePayload) { + const dist = Math.hypot(xy.x - origin.x, xy.y - origin.y); + if (dist < CLICK_VS_DRAG_PX) { + finishDrag('cancel', xy); + } else { finishDrag('end', xy); } }, - { preventTouchScroll: true }, ); - - if (gestureCaptureEl) { - const el = gestureCaptureEl; - const pid = pointerId; - const onElMove = (ev: PointerEvent) => { - if (ev.pointerId !== pid) return; - const xy = { x: ev.clientX, y: ev.clientY }; - if (!dragging) { - const dx = xy.x - startX; - const dy = xy.y - startY; - if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; - dragging = true; - mountDragOverlay(payload, xy, pointerId); - return; - } - notifyPaletteDragMove(xy.x, xy.y); - }; - const onElEnd = (ev: PointerEvent) => { - if (ev.pointerId !== pid) return; - el.removeEventListener('pointermove', onElMove); - el.removeEventListener('pointerup', onElEnd); - el.removeEventListener('pointercancel', onElEnd); - unbindGestureListeners(); - if (dragging && activePayload) { - finishDrag('end', { x: ev.clientX, y: ev.clientY }); - } - }; - el.addEventListener('pointermove', onElMove); - el.addEventListener('pointerup', onElEnd); - el.addEventListener('pointercancel', onElEnd); - const windowUnbind = unbindGesture; - unbindGesture = () => { - el.removeEventListener('pointermove', onElMove); - el.removeEventListener('pointerup', onElEnd); - el.removeEventListener('pointercancel', onElEnd); - windowUnbind?.(); - }; - } } export function handlePalettePointerDown( @@ -358,23 +261,16 @@ export function handlePalettePointerDown( ): void { if (!needsPointerPaletteDrag()) return; if (!isPrimaryPointerButton(e)) return; - if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; + const target = e.target as HTMLElement; + if (target.closest('button')) return; + const input = target.closest('input, textarea, select') as HTMLInputElement | null; + if (input && !input.readOnly && !input.disabled) return; e.preventDefault(); - e.stopPropagation(); - - const sourceEl = e.currentTarget as HTMLElement; - try { - sourceEl.setPointerCapture(e.pointerId); - gestureCaptureEl = sourceEl; - } catch { - /* bindWindowDrag 가 move/end 처리 */ - } startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload); } -/** macOS WebView 등 — pointer 대신 mouse 이벤트만 오는 경우 */ export function handlePaletteMouseDown( e: React.MouseEvent, payload: PaletteDragPayload, @@ -382,15 +278,16 @@ export function handlePaletteMouseDown( ): void { if (!needsPointerPaletteDrag()) return; if (e.button !== 0) return; - if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; + const target = e.target as HTMLElement; + if (target.closest('button')) return; + const input = target.closest('input, textarea, select') as HTMLInputElement | null; + if (input && !input.readOnly && !input.disabled) return; if (typeof window !== 'undefined' && 'PointerEvent' in window) return; e.preventDefault(); - e.stopPropagation(); startPaletteDragGesture(e.clientX, e.clientY, 1, payload); } -/** PointerEvent 미지원 — touchstart 전용 */ export function handlePaletteTouchStart( e: React.TouchEvent, payload: PaletteDragPayload, @@ -406,12 +303,77 @@ export function handlePaletteTouchStart( startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload); } +/* ── Web HTML5 ── */ + +function installHtmlPaletteDragGlobals() { + if (typeof window === 'undefined') return; + const w = window as Window & { __gcPaletteHtmlDragGlobals?: boolean }; + if (w.__gcPaletteHtmlDragGlobals) return; + w.__gcPaletteHtmlDragGlobals = true; + + window.addEventListener('dragover', (e) => { + if (!htmlPaletteDragActive) return; + if (e.clientX !== 0 || e.clientY !== 0) { + lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; + if (activeHtmlDragPayload) { + emit({ + phase: 'move', + x: e.clientX, + y: e.clientY, + payload: activeHtmlDragPayload, + }); + } + } + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }, { passive: false }); + + window.addEventListener('drag', (e) => { + if (!htmlPaletteDragActive || !activeHtmlDragPayload) return; + if (e.clientX !== 0 || e.clientY !== 0) { + lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; + emit({ + phase: 'move', + x: e.clientX, + y: e.clientY, + payload: activeHtmlDragPayload, + }); + } + }); + + window.addEventListener('dragend', () => { + const payload = activeHtmlDragPayload; + const xy = lastHtmlDragClientXY; + if (htmlPaletteDragActive && !htmlDropCommitted && payload && xy && htmlDropRouter) { + htmlDropRouter(xy.x, xy.y, payload); + } + htmlDropCommitted = false; + stopHtmlDragMouseTracking(); + htmlPaletteDragActive = false; + lastHtmlDragClientXY = null; + activeHtmlDragPayload = null; + }); + + document.addEventListener('drop', (e) => { + if (!htmlPaletteDragActive) return; + e.preventDefault(); + e.stopPropagation(); + const payload = readPaletteHtmlDragDataFromEvent(e) ?? activeHtmlDragPayload; + if (!payload || !htmlDropRouter || htmlDropCommitted) return; + htmlDropCommitted = true; + const x = e.clientX !== 0 || e.clientY !== 0 ? e.clientX : (lastHtmlDragClientXY?.x ?? 0); + const y = e.clientX !== 0 || e.clientY !== 0 ? e.clientY : (lastHtmlDragClientXY?.y ?? 0); + htmlDropRouter(x, y, payload); + }, true); +} + +installHtmlPaletteDragGlobals(); + function stopHtmlDragMouseTracking(): void { htmlDragMouseTrackUnbind?.(); htmlDragMouseTrackUnbind = null; } -/** HTML5 drag 중 mousemove 로 preview 좌표 추적 (macOS WKWebView — dragover 좌표/hit-test 불안정) */ export function startHtmlDragMouseTracking( clientX: number, clientY: number, @@ -427,9 +389,7 @@ export function startHtmlDragMouseTracking( lastHtmlDragClientXY = xy; emit({ phase: 'move', x: xy.x, y: xy.y, payload }); }, - () => { - /* native HTML5 drag 중 mouseup 은 dragend 전에 올 수 있음 — dragend 에서 정리 */ - }, + () => { /* dragend 에서 정리 */ }, { preventTouchScroll: false }, ); } @@ -441,6 +401,7 @@ export function endHtmlDragMouseTracking(): void { export function markPaletteHtmlDragStart(): void { htmlPaletteDragActive = true; + htmlDropCommitted = false; lastHtmlDragClientXY = null; } diff --git a/frontend/src/utils/pointerDrag.ts b/frontend/src/utils/pointerDrag.ts index 566e4e6..475e645 100644 --- a/frontend/src/utils/pointerDrag.ts +++ b/frontend/src/utils/pointerDrag.ts @@ -44,6 +44,39 @@ export interface BindWindowDragOptions { useCapture?: boolean; } +/** + * document capture 단계 pointer 추적 (스크롤 패널 내부 드래그 — WKWebView 대응) + */ +export function bindDocumentPointerDrag( + pointerId: number, + onMove: (xy: ClientXY) => void, + onEnd: (xy: ClientXY) => void, +): () => void { + const cap: AddEventListenerOptions = { capture: true, passive: false }; + + const move = (ev: Event) => { + if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return; + if (ev.cancelable) ev.preventDefault(); + onMove({ x: ev.clientX, y: ev.clientY }); + }; + + const end = (ev: Event) => { + if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return; + if (ev.cancelable) ev.preventDefault(); + onEnd({ x: ev.clientX, y: ev.clientY }); + }; + + document.addEventListener('pointermove', move, cap); + document.addEventListener('pointerup', end, cap); + document.addEventListener('pointercancel', end, cap); + + return () => { + document.removeEventListener('pointermove', move, cap); + document.removeEventListener('pointerup', end, cap); + document.removeEventListener('pointercancel', end, cap); + }; +} + /** * 드래그 시작 후 window 에 move/end 리스너 등록. * pointer + mouse + touch 모두 수신 (구형 브라우저·iOS 대응). diff --git a/package-lock.json b/package-lock.json index a20b703..b2bcce7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ }, "desktop": { "name": "@goldenchart/desktop", - "version": "0.1.4", + "version": "0.1.43", "dependencies": { "@goldenchart/shared": "*", "@stomp/stompjs": "^7.3.0",