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",