전략편집기 수정

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';
/**
* Tauri WebView — 드래그 중 전체 화면 overlay 가 pointer 이벤트·drop 을 수신
* Tauri WebView — 드래그 overlay (라벨 위치는 ref 갱신, move 마다 React re-render 없음)
*/
export default function PaletteDragOverlay() {
const [session, setSession] = useState<PaletteDragOverlaySession | null>(null);
const [cursor, setCursor] = useState({ x: 0, y: 0 });
const overlayRef = useRef<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
registerPaletteDragOverlay({
mount: next => {
setCursor({ x: next.x, y: next.y });
setSession(next);
requestAnimationFrame(() => {
const label = labelRef.current;
if (label) {
label.style.left = `${next.x}px`;
label.style.top = `${next.y}px`;
}
});
},
unmount: () => setSession(null),
bindElement: el => {
@@ -41,7 +47,7 @@ export default function PaletteDragOverlay() {
try {
overlayRef.current.setPointerCapture(session.pointerId);
} catch {
/* body capture fallback */
/* ignore */
}
}, [session]);
@@ -49,7 +55,11 @@ export default function PaletteDragOverlay() {
if (!session || e.pointerId !== session.pointerId) return;
e.preventDefault();
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);
}, [session]);
@@ -78,8 +88,9 @@ export default function PaletteDragOverlay() {
onPointerCancel={onPointerCancel}
>
<div
ref={labelRef}
className="se-palette-drag-overlay-label"
style={{ left: cursor.x, top: cursor.y }}
style={{ left: session.x, top: session.y }}
>
{session.payload.label}
</div>
@@ -1281,16 +1281,22 @@ function StrategyEditorCanvasInner({
updatePreviewRef.current = updateDropPreview;
const resolveDropFromClientRef = useRef(resolveDropFromClient);
resolveDropFromClientRef.current = resolveDropFromClient;
const lastPreviewAnchorRef = useRef<string | null>(null);
useEffect(() => {
if (!needsPointerPaletteDrag()) return;
return subscribePaletteDrag((ev: PaletteDragEvent) => {
if (ev.phase === 'move') {
if (!isOverStrategyBuilder(ev.x, ev.y)) {
if (lastPreviewAnchorRef.current != null) {
lastPreviewAnchorRef.current = null;
clearPreviewRef.current();
}
return;
}
const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y);
if (anchorId === lastPreviewAnchorRef.current) return;
lastPreviewAnchorRef.current = anchorId;
if (anchorId) {
updatePreviewRef.current(anchorId, flowPos);
} else {
@@ -1298,6 +1304,9 @@ function StrategyEditorCanvasInner({
}
return;
}
if (ev.phase === 'end' || ev.phase === 'cancel') {
lastPreviewAnchorRef.current = null;
}
if (ev.phase === 'cancel') {
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 {
CondEditor,
@@ -31,6 +31,15 @@ import {
import StartTimeframeCheckboxes from './StartTimeframeCheckboxes';
import LogicGateOpToggle from './LogicGateOpToggle';
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> = {
AND: '#4caf50',
@@ -134,6 +143,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
<div
className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`}
style={{ marginLeft: depth > 0 ? 12 : 0 }}
data-palette-drop-key={isLogic ? nodeDropKey : undefined}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -327,6 +337,7 @@ function StartSectionBlock({
<div
className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`}
data-palette-drop-key={sectionDropKey}
onDragOver={e => {
e.preventDefault();
e.stopPropagation();
@@ -380,8 +391,15 @@ export default function StrategyListEditor({
orphans = [],
}: Props) {
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
const dragOverKeyRef = useRef<string | null>(null);
const sections = collectStartSections(editorState);
const setDragOverKeyThrottled = useCallback((key: string | null) => {
if (dragOverKeyRef.current === key) return;
dragOverKeyRef.current = key;
setDragOverKey(key);
}, []);
const handleAddStart = useCallback(() => {
if (onAddStart) {
onAddStart();
@@ -406,6 +424,92 @@ export default function StrategyListEditor({
}
}, [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) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
@@ -429,6 +533,7 @@ export default function StrategyListEditor({
return (
<div
className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`}
data-palette-drop-key={LIST_DROP_KEY}
onDragOver={handleListDragOver}
onDragLeave={() => setDragOverKey(null)}
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 {
for (const el of elementsUnderDragPoint(clientX, clientY)) {
+21 -4
View File
@@ -1,6 +1,7 @@
/**
* Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그
*/
import { elementsUnderDragPoint } from './flowScreenProjection';
import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & {
@@ -43,6 +44,8 @@ let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null;
let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null;
let moveRafId: number | null = null;
let pendingMove: { x: number; y: number } | null = null;
export function needsPointerPaletteDrag(): boolean {
return isDesktop();
@@ -88,6 +91,11 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload)
function cleanupDrag() {
activePayload = null;
activePointerId = null;
pendingMove = null;
if (moveRafId != null) {
cancelAnimationFrame(moveRafId);
moveRafId = null;
}
document.body.classList.remove('se-palette-drag-active');
overlayController?.unmount();
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) {
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) {
@@ -229,8 +245,9 @@ export function readPaletteHtmlDragData(e: React.DragEvent): PaletteDragPayload
}
export function findPaletteDropKeyAtPoint(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y);
if (!el) return null;
for (const el of elementsUnderDragPoint(x, y)) {
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;
}