전략편집기 수정
This commit is contained in:
@@ -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)) {
|
||||||
clearPreviewRef.current();
|
if (lastPreviewAnchorRef.current != null) {
|
||||||
|
lastPreviewAnchorRef.current = null;
|
||||||
|
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)) {
|
||||||
|
|||||||
@@ -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;
|
if (host?.dataset.paletteDropKey) return host.dataset.paletteDropKey;
|
||||||
return host?.dataset.paletteDropKey ?? null;
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user