전략편집기 수정

This commit is contained in:
Macbook
2026-06-14 23:56:27 +09:00
parent 198ebbb3d7
commit 55a517ba1c
5 changed files with 163 additions and 13 deletions
@@ -10,18 +10,24 @@ import {
} from '../../utils/paletteDragSession'; } from '../../utils/paletteDragSession';
/** /**
* Tauri WebView — 드래그 중 전체 화면 overlay 가 pointer 이벤트·drop 을 수신 * Tauri WebView — 드래그 overlay (라벨 위치는 ref 갱신, move 마다 React re-render 없음)
*/ */
export default function PaletteDragOverlay() { export default function PaletteDragOverlay() {
const [session, setSession] = useState<PaletteDragOverlaySession | null>(null); const [session, setSession] = useState<PaletteDragOverlaySession | null>(null);
const [cursor, setCursor] = useState({ x: 0, y: 0 });
const overlayRef = useRef<HTMLDivElement | null>(null); const overlayRef = useRef<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
registerPaletteDragOverlay({ registerPaletteDragOverlay({
mount: next => { mount: next => {
setCursor({ x: next.x, y: next.y });
setSession(next); setSession(next);
requestAnimationFrame(() => {
const label = labelRef.current;
if (label) {
label.style.left = `${next.x}px`;
label.style.top = `${next.y}px`;
}
});
}, },
unmount: () => setSession(null), unmount: () => setSession(null),
bindElement: el => { bindElement: el => {
@@ -41,7 +47,7 @@ export default function PaletteDragOverlay() {
try { try {
overlayRef.current.setPointerCapture(session.pointerId); overlayRef.current.setPointerCapture(session.pointerId);
} catch { } catch {
/* body capture fallback */ /* ignore */
} }
}, [session]); }, [session]);
@@ -49,7 +55,11 @@ export default function PaletteDragOverlay() {
if (!session || e.pointerId !== session.pointerId) return; if (!session || e.pointerId !== session.pointerId) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setCursor({ x: e.clientX, y: e.clientY }); const label = labelRef.current;
if (label) {
label.style.left = `${e.clientX}px`;
label.style.top = `${e.clientY}px`;
}
notifyPaletteDragMove(e.clientX, e.clientY); notifyPaletteDragMove(e.clientX, e.clientY);
}, [session]); }, [session]);
@@ -78,8 +88,9 @@ export default function PaletteDragOverlay() {
onPointerCancel={onPointerCancel} onPointerCancel={onPointerCancel}
> >
<div <div
ref={labelRef}
className="se-palette-drag-overlay-label" className="se-palette-drag-overlay-label"
style={{ left: cursor.x, top: cursor.y }} style={{ left: session.x, top: session.y }}
> >
{session.payload.label} {session.payload.label}
</div> </div>
@@ -1281,16 +1281,22 @@ function StrategyEditorCanvasInner({
updatePreviewRef.current = updateDropPreview; updatePreviewRef.current = updateDropPreview;
const resolveDropFromClientRef = useRef(resolveDropFromClient); const resolveDropFromClientRef = useRef(resolveDropFromClient);
resolveDropFromClientRef.current = resolveDropFromClient; resolveDropFromClientRef.current = resolveDropFromClient;
const lastPreviewAnchorRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!needsPointerPaletteDrag()) return; if (!needsPointerPaletteDrag()) return;
return subscribePaletteDrag((ev: PaletteDragEvent) => { return subscribePaletteDrag((ev: PaletteDragEvent) => {
if (ev.phase === 'move') { if (ev.phase === 'move') {
if (!isOverStrategyBuilder(ev.x, ev.y)) { if (!isOverStrategyBuilder(ev.x, ev.y)) {
if (lastPreviewAnchorRef.current != null) {
lastPreviewAnchorRef.current = null;
clearPreviewRef.current(); clearPreviewRef.current();
}
return; return;
} }
const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y); const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y);
if (anchorId === lastPreviewAnchorRef.current) return;
lastPreviewAnchorRef.current = anchorId;
if (anchorId) { if (anchorId) {
updatePreviewRef.current(anchorId, flowPos); updatePreviewRef.current(anchorId, flowPos);
} else { } else {
@@ -1298,6 +1304,9 @@ function StrategyEditorCanvasInner({
} }
return; return;
} }
if (ev.phase === 'end' || ev.phase === 'cancel') {
lastPreviewAnchorRef.current = null;
}
if (ev.phase === 'cancel') { if (ev.phase === 'cancel') {
clearPreviewRef.current(); clearPreviewRef.current();
} }
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { LogicNode } from '../../utils/strategyTypes'; import type { LogicNode } from '../../utils/strategyTypes';
import { import {
CondEditor, CondEditor,
@@ -31,6 +31,15 @@ import {
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes'; import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
import LogicGateOpToggle from './LogicGateOpToggle'; import LogicGateOpToggle from './LogicGateOpToggle';
import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes'; import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
import { isOverListEditor } from '../../utils/flowScreenProjection';
import {
findPaletteDropKeyAtPoint,
needsPointerPaletteDrag,
setPaletteDragDropHandler,
subscribePaletteDrag,
type PaletteDragPayload,
} from '../../utils/paletteDragSession';
const NODE_COLORS: Record<string, string> = { const NODE_COLORS: Record<string, string> = {
AND: '#4caf50', AND: '#4caf50',
@@ -134,6 +143,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
<div <div
className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`} className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`}
style={{ marginLeft: depth > 0 ? 12 : 0 }} style={{ marginLeft: depth > 0 ? 12 : 0 }}
data-palette-drop-key={isLogic ? nodeDropKey : undefined}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -327,6 +337,7 @@ function StartSectionBlock({
<div <div
className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`} className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`}
data-palette-drop-key={sectionDropKey}
onDragOver={e => { onDragOver={e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -380,8 +391,15 @@ export default function StrategyListEditor({
orphans = [], orphans = [],
}: Props) { }: Props) {
const [dragOverKey, setDragOverKey] = useState<string | null>(null); const [dragOverKey, setDragOverKey] = useState<string | null>(null);
const dragOverKeyRef = useRef<string | null>(null);
const sections = collectStartSections(editorState); const sections = collectStartSections(editorState);
const setDragOverKeyThrottled = useCallback((key: string | null) => {
if (dragOverKeyRef.current === key) return;
dragOverKeyRef.current = key;
setDragOverKey(key);
}, []);
const handleAddStart = useCallback(() => { const handleAddStart = useCallback(() => {
if (onAddStart) { if (onAddStart) {
onAddStart(); onAddStart();
@@ -406,6 +424,92 @@ export default function StrategyListEditor({
} }
}, [editorState, onEditorStateChange, onOrphansChange, orphans]); }, [editorState, onEditorStateChange, onOrphansChange, orphans]);
const resolvePaletteNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
) => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
const applyPaletteDropAtKey = useCallback((
dropKey: string | null,
data: PaletteDragPayload,
) => {
if (!dropKey) return;
if (dropKey === LIST_DROP_KEY) {
if (data.type === 'start') handleAddStart();
return;
}
const rootMatch = dropKey.match(/^([^:]+):__root__$/);
const nodeMatch = dropKey.match(/^([^:]+):([^:]+)$/);
const startId = rootMatch?.[1] ?? nodeMatch?.[1];
const targetId = rootMatch ? null : nodeMatch?.[2];
if (!startId) return;
if (data.type === 'start') {
handleAddStart();
return;
}
const section = collectStartSections(editorState).find(s => s.startId === startId);
if (!section) return;
const newNode = resolvePaletteNode(data);
if (!newNode) return;
const root = section.root;
if (!root) {
handleRootChange(startId, newNode);
return;
}
if (!targetId) {
handleRootChange(startId, mergeAtRoot(root, newNode, data.type === 'operator'));
return;
}
handleRootChange(startId, addChild(root, targetId, newNode));
}, [
editorState,
handleAddStart,
handleRootChange,
resolvePaletteNode,
]);
const applyDropRef = useRef(applyPaletteDropAtKey);
applyDropRef.current = applyPaletteDropAtKey;
useLayoutEffect(() => {
if (!needsPointerPaletteDrag()) return undefined;
setPaletteDragDropHandler(ev => {
if (ev.phase !== 'end') return;
dragOverKeyRef.current = null;
setDragOverKey(null);
if (!isOverListEditor(ev.x, ev.y)) return;
applyDropRef.current(findPaletteDropKeyAtPoint(ev.x, ev.y), ev.payload);
});
return () => setPaletteDragDropHandler(null);
}, []);
useEffect(() => {
if (!needsPointerPaletteDrag()) return;
return subscribePaletteDrag(ev => {
if (ev.phase === 'move') {
if (!isOverListEditor(ev.x, ev.y)) {
setDragOverKeyThrottled(null);
return;
}
setDragOverKeyThrottled(findPaletteDropKeyAtPoint(ev.x, ev.y));
return;
}
if (ev.phase === 'cancel') {
dragOverKeyRef.current = null;
setDragOverKey(null);
}
});
}, [setDragOverKeyThrottled]);
const handleListDragOver = (e: React.DragEvent) => { const handleListDragOver = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
@@ -429,6 +533,7 @@ export default function StrategyListEditor({
return ( return (
<div <div
className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`} className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`}
data-palette-drop-key={LIST_DROP_KEY}
onDragOver={handleListDragOver} onDragOver={handleListDragOver}
onDragLeave={() => setDragOverKey(null)} onDragLeave={() => setDragOverKey(null)}
onDrop={handleListDrop} onDrop={handleListDrop}
@@ -72,6 +72,14 @@ export function projectScreenToFlowPosition(
); );
} }
/** 목록 방식 편집기 위인지 */
export function isOverListEditor(clientX: number, clientY: number): boolean {
for (const el of elementsUnderDragPoint(clientX, clientY)) {
if (el.closest('.se-list-editor')) return true;
}
return false;
}
/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */ /** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */
export function isOverStrategyBuilder(clientX: number, clientY: number): boolean { export function isOverStrategyBuilder(clientX: number, clientY: number): boolean {
for (const el of elementsUnderDragPoint(clientX, clientY)) { for (const el of elementsUnderDragPoint(clientX, clientY)) {
+21 -4
View File
@@ -1,6 +1,7 @@
/** /**
* Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그 * Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그
*/ */
import { elementsUnderDragPoint } from './flowScreenProjection';
import { isDesktop } from './platform'; import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & { export type PaletteDragPayload = Record<string, unknown> & {
@@ -43,6 +44,8 @@ let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null; let overlayElement: HTMLElement | null = null;
let listeners = new Set<PaletteDragListener>(); let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null; let dropHandler: PaletteDropHandler | null = null;
let moveRafId: number | null = null;
let pendingMove: { x: number; y: number } | null = null;
export function needsPointerPaletteDrag(): boolean { export function needsPointerPaletteDrag(): boolean {
return isDesktop(); return isDesktop();
@@ -88,6 +91,11 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload)
function cleanupDrag() { function cleanupDrag() {
activePayload = null; activePayload = null;
activePointerId = null; activePointerId = null;
pendingMove = null;
if (moveRafId != null) {
cancelAnimationFrame(moveRafId);
moveRafId = null;
}
document.body.classList.remove('se-palette-drag-active'); document.body.classList.remove('se-palette-drag-active');
overlayController?.unmount(); overlayController?.unmount();
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
@@ -107,7 +115,15 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
export function notifyPaletteDragMove(clientX: number, clientY: number) { export function notifyPaletteDragMove(clientX: number, clientY: number) {
if (!activePayload) return; if (!activePayload) return;
emit({ phase: 'move', x: clientX, y: clientY, payload: activePayload }); pendingMove = { x: clientX, y: clientY };
if (moveRafId != null) return;
moveRafId = requestAnimationFrame(() => {
moveRafId = null;
if (!pendingMove || !activePayload) return;
const { x, y } = pendingMove;
pendingMove = null;
emit({ phase: 'move', x, y, payload: activePayload });
});
} }
export function notifyPaletteDragEnd(clientX: number, clientY: number) { export function notifyPaletteDragEnd(clientX: number, clientY: number) {
@@ -229,8 +245,9 @@ export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload
} }
export function findPaletteDropKeyAtPoint(x: number, y: number): string | null { export function findPaletteDropKeyAtPoint(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y); for (const el of elementsUnderDragPoint(x, y)) {
if (!el) return null;
const host = el.closest('[data-palette-drop-key]') as HTMLElement | null; const host = el.closest('[data-palette-drop-key]') as HTMLElement | null;
return host?.dataset.paletteDropKey ?? null; if (host?.dataset.paletteDropKey) return host.dataset.paletteDropKey;
}
return null;
} }