전략편집기 목록방식 추가오류 수정
This commit is contained in:
@@ -7599,7 +7599,8 @@ html.desktop-client .tmb-logo-version {
|
|||||||
background: var(--bg3);
|
background: var(--bg3);
|
||||||
transition: border-color .15s;
|
transition: border-color .15s;
|
||||||
}
|
}
|
||||||
.sp-tree-node--over {
|
.sp-tree-node--over,
|
||||||
|
.sp-tree-node.sp-drag-over {
|
||||||
border-color: var(--accent) !important;
|
border-color: var(--accent) !important;
|
||||||
box-shadow: 0 0 0 2px rgba(122,162,247,.25);
|
box-shadow: 0 0 0 2px rgba(122,162,247,.25);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default function ComboFieldSelect({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clamped = clampValue(parsed, min, max, allowDecimal);
|
const clamped = clampValue(parsed, min, max, allowDecimal);
|
||||||
|
if (clamped === customNumber) return;
|
||||||
onCustomNumberChange(clamped);
|
onCustomNumberChange(clamped);
|
||||||
setDraft(String(clamped));
|
setDraft(String(clamped));
|
||||||
}, [draft, allowDecimal, min, max, onCustomNumberChange, customNumber]);
|
}, [draft, allowDecimal, min, max, onCustomNumberChange, customNumber]);
|
||||||
@@ -120,8 +121,15 @@ export default function ComboFieldSelect({
|
|||||||
const commitRef = useRef(commitCustom);
|
const commitRef = useRef(commitCustom);
|
||||||
commitRef.current = commitCustom;
|
commitRef.current = commitCustom;
|
||||||
|
|
||||||
// 직접입력 blur 전 탭 전환·저장 시 값 유실 방지
|
const focusedRef = useRef(false);
|
||||||
useEffect(() => () => { commitRef.current(); }, []);
|
useEffect(() => {
|
||||||
|
focusedRef.current = focused;
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
// 직접입력 blur 전 탭 전환·저장 시 값 유실 방지 (포커스 중일 때만 — unmount 시 stale onChange 방지)
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (focusedRef.current) commitRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focused || mode !== 'custom') return;
|
if (!focused || mode !== 'custom') return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
makeNodeOptionsFromPalette,
|
makeNodeOptionsFromPalette,
|
||||||
mergeAtRoot,
|
mergeAtRoot,
|
||||||
nodeToText,
|
nodeToText,
|
||||||
|
updateNode,
|
||||||
type DefType,
|
type DefType,
|
||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
@@ -35,13 +36,11 @@ import { formatStartCandleTypesLabel } from '../../utils/strategyStartNodes';
|
|||||||
import { START_NODE_ID } from '../../utils/strategyFlowLayout';
|
import { START_NODE_ID } from '../../utils/strategyFlowLayout';
|
||||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
||||||
import {
|
import {
|
||||||
|
clearPaletteDropHighlights,
|
||||||
findPaletteDropKeyAtPoint,
|
findPaletteDropKeyAtPoint,
|
||||||
needsPointerPaletteDrag,
|
needsPointerPaletteDrag,
|
||||||
readPaletteHtmlDragData,
|
|
||||||
setHtmlPaletteDropRouter,
|
setHtmlPaletteDropRouter,
|
||||||
setPaletteDragDropHandler,
|
setPaletteDragDropHandler,
|
||||||
subscribePaletteDrag,
|
|
||||||
wasPaletteHtmlDropCommitted,
|
|
||||||
type PaletteDragPayload,
|
type PaletteDragPayload,
|
||||||
} from '../../utils/paletteDragSession';
|
} from '../../utils/paletteDragSession';
|
||||||
|
|
||||||
@@ -53,18 +52,20 @@ const NODE_COLORS: Record<string, string> = {
|
|||||||
const IND_COLOR = '#9c27b0';
|
const IND_COLOR = '#9c27b0';
|
||||||
const LIST_DROP_KEY = '__list__';
|
const LIST_DROP_KEY = '__list__';
|
||||||
|
|
||||||
|
/** HTML5 드롭 허용 — 하이라이트·로컬 drop 은 전역 라우터만 사용 (DOM 교체 시 dragenter 루프 방지) */
|
||||||
|
const allowPaletteDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||||
|
};
|
||||||
|
|
||||||
interface TreeNodeProps {
|
interface TreeNodeProps {
|
||||||
node: LogicNode;
|
node: LogicNode;
|
||||||
signalType: 'buy' | 'sell';
|
signalType: 'buy' | 'sell';
|
||||||
onUpdate: (n: LogicNode) => void;
|
onUpdate: (n: LogicNode | ((prev: LogicNode) => LogicNode)) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onDropOnNode?: (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => void;
|
|
||||||
dragOverKey?: string | null;
|
|
||||||
setDragOverKey?: (key: string | null) => void;
|
|
||||||
dropScope: string;
|
dropScope: string;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
def: DefType;
|
def: DefType;
|
||||||
onAddStart?: () => void;
|
|
||||||
stochPairForest?: (LogicNode | null)[];
|
stochPairForest?: (LogicNode | null)[];
|
||||||
onStochPairSecondaryChange?: (orNodeId: string, secondary: string) => void;
|
onStochPairSecondaryChange?: (orNodeId: string, secondary: string) => void;
|
||||||
}
|
}
|
||||||
@@ -74,13 +75,9 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
signalType,
|
signalType,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDropOnNode,
|
|
||||||
dragOverKey,
|
|
||||||
setDragOverKey,
|
|
||||||
dropScope,
|
dropScope,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
def,
|
def,
|
||||||
onAddStart,
|
|
||||||
stochPairForest,
|
stochPairForest,
|
||||||
onStochPairSecondaryChange,
|
onStochPairSecondaryChange,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -103,37 +100,9 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
? 'OR (또는)'
|
? 'OR (또는)'
|
||||||
: 'NOT (반대)';
|
: 'NOT (반대)';
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleCondChange = (c: NonNullable<LogicNode['condition']>) => {
|
||||||
if (!isLogic) return;
|
onUpdate(prev => updateNode(prev, node.id, n => ({ ...n, condition: c })));
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverKey?.(nodeDropKey);
|
|
||||||
};
|
};
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
const rel = e.relatedTarget as Node | null;
|
|
||||||
if (!rel) return;
|
|
||||||
if (e.currentTarget.contains(rel)) return;
|
|
||||||
setDragOverKey?.(null);
|
|
||||||
};
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
if (!isLogic) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverKey?.(null);
|
|
||||||
if (wasPaletteHtmlDropCommitted()) return;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
|
||||||
if (data.type === 'start') {
|
|
||||||
onAddStart?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onDropOnNode?.(node.id, data);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCondChange = (c: NonNullable<LogicNode['condition']>) => onUpdate({ ...node, condition: c });
|
|
||||||
|
|
||||||
const stochPairRoot = stochPairForest && node.condition
|
const stochPairRoot = stochPairForest && node.condition
|
||||||
? findStochPairRootInForest(stochPairForest, node.id)
|
? findStochPairRootInForest(stochPairForest, node.id)
|
||||||
@@ -146,19 +115,29 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleChildUpdate = (childId: string, updated: LogicNode) =>
|
const handleChildUpdate = (
|
||||||
onUpdate({ ...node, children: (node.children ?? []).map(c => (c.id === childId ? updated : c)) });
|
childId: string,
|
||||||
const handleChildDelete = (childId: string) =>
|
updated: LogicNode | ((prev: LogicNode) => LogicNode),
|
||||||
onUpdate({ ...node, children: (node.children ?? []).filter(c => c.id !== childId) });
|
) => {
|
||||||
|
if (typeof updated === 'function') {
|
||||||
|
onUpdate(updated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUpdate(prev => updateNode(prev, childId, () => updated));
|
||||||
|
};
|
||||||
|
const handleChildDelete = (childId: string) => {
|
||||||
|
onUpdate(prev => updateNode(prev, node.id, n => ({
|
||||||
|
...n,
|
||||||
|
children: (n.children ?? []).filter(c => c.id !== childId),
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`sp-tree-node${dragOverKey === nodeDropKey ? ' sp-tree-node--over' : ''}`}
|
className="sp-tree-node"
|
||||||
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
style={{ marginLeft: depth > 0 ? 12 : 0 }}
|
||||||
data-palette-drop-key={isLogic ? nodeDropKey : undefined}
|
data-palette-drop-key={isLogic ? nodeDropKey : undefined}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={isLogic ? allowPaletteDrop : undefined}
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
>
|
||||||
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
||||||
<span className="sp-node-badge" style={{ background: color }}>
|
<span className="sp-node-badge" style={{ background: color }}>
|
||||||
@@ -173,11 +152,11 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
{(node.type === 'AND' || node.type === 'OR') && (
|
{(node.type === 'AND' || node.type === 'OR') && (
|
||||||
<LogicGateOpToggle
|
<LogicGateOpToggle
|
||||||
value={node.type}
|
value={node.type}
|
||||||
onChange={op => onUpdate({
|
onChange={op => onUpdate(prev => updateNode(prev, node.id, n => ({
|
||||||
...node,
|
...n,
|
||||||
type: op,
|
type: op,
|
||||||
stochPair: op === 'OR' ? node.stochPair : undefined,
|
stochPair: op === 'OR' ? n.stochPair : undefined,
|
||||||
})}
|
})))}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -228,7 +207,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
signalType={signalType}
|
signalType={signalType}
|
||||||
nodeId={node.id}
|
nodeId={node.id}
|
||||||
onChange={handleCondChange}
|
onChange={handleCondChange}
|
||||||
onReplaceNode={onUpdate}
|
onReplaceNode={next => onUpdate(prev => updateNode(prev, node.id, () => next))}
|
||||||
onClose={() => setPresetOpen(false)}
|
onClose={() => setPresetOpen(false)}
|
||||||
popoverClassName="sp-node-preset-pop"
|
popoverClassName="sp-node-preset-pop"
|
||||||
/>
|
/>
|
||||||
@@ -246,7 +225,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
{settingsOpen && isStochPair && node.stochPair && (
|
{settingsOpen && isStochPair && node.stochPair && (
|
||||||
<StochPairNodeSettings
|
<StochPairNodeSettings
|
||||||
secondaryIndicator={node.stochPair.secondaryIndicatorType}
|
secondaryIndicator={node.stochPair.secondaryIndicatorType}
|
||||||
onChange={sec => onUpdate(setStochPairSecondary(node, sec, def))}
|
onChange={sec => onUpdate(prev => updateNode(prev, node.id, n => setStochPairSecondary(n, sec, def)))}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
popoverClassName="sp-node-settings-pop"
|
popoverClassName="sp-node-settings-pop"
|
||||||
/>
|
/>
|
||||||
@@ -272,20 +251,13 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
signalType={signalType}
|
signalType={signalType}
|
||||||
onUpdate={u => handleChildUpdate(child.id, u)}
|
onUpdate={u => handleChildUpdate(child.id, u)}
|
||||||
onDelete={() => handleChildDelete(child.id)}
|
onDelete={() => handleChildDelete(child.id)}
|
||||||
onDropOnNode={onDropOnNode}
|
|
||||||
dragOverKey={dragOverKey}
|
|
||||||
setDragOverKey={setDragOverKey}
|
|
||||||
dropScope={dropScope}
|
dropScope={dropScope}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
def={def}
|
def={def}
|
||||||
onAddStart={onAddStart}
|
|
||||||
stochPairForest={stochPairForest}
|
stochPairForest={stochPairForest}
|
||||||
onStochPairSecondaryChange={onStochPairSecondaryChange}
|
onStochPairSecondaryChange={onStochPairSecondaryChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{dragOverKey === nodeDropKey && (
|
|
||||||
<div className="sp-drop-hint-inner">여기에 드롭하세요</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,12 +271,9 @@ interface StartSectionBlockProps {
|
|||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
signalTab: 'buy' | 'sell';
|
signalTab: 'buy' | 'sell';
|
||||||
def: DefType;
|
def: DefType;
|
||||||
dragOverKey: string | null;
|
|
||||||
setDragOverKey: (key: string | null) => void;
|
|
||||||
onRootChange: (root: LogicNode | null | ((prev: LogicNode | null) => LogicNode | null)) => void;
|
onRootChange: (root: LogicNode | null | ((prev: LogicNode | null) => LogicNode | null)) => void;
|
||||||
onCandleTypesChange: (candleTypes: string[]) => void;
|
onCandleTypesChange: (candleTypes: string[]) => void;
|
||||||
onDeleteStart?: () => void;
|
onDeleteStart?: () => void;
|
||||||
onAddStart?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StartSectionBlock({
|
function StartSectionBlock({
|
||||||
@@ -314,51 +283,16 @@ function StartSectionBlock({
|
|||||||
canDelete,
|
canDelete,
|
||||||
signalTab,
|
signalTab,
|
||||||
def,
|
def,
|
||||||
dragOverKey,
|
|
||||||
setDragOverKey,
|
|
||||||
onRootChange,
|
onRootChange,
|
||||||
onCandleTypesChange,
|
onCandleTypesChange,
|
||||||
onDeleteStart,
|
onDeleteStart,
|
||||||
onAddStart,
|
|
||||||
}: StartSectionBlockProps) {
|
}: StartSectionBlockProps) {
|
||||||
const sectionDropKey = `${startId}:__root__`;
|
const sectionDropKey = `${startId}:__root__`;
|
||||||
|
|
||||||
const applyDrop = useCallback((
|
|
||||||
targetId: string | null,
|
|
||||||
data: { type: string; value: string; label: string; composite?: boolean },
|
|
||||||
) => {
|
|
||||||
if (data.type === 'start') {
|
|
||||||
onAddStart?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
|
||||||
onRootChange(prev => {
|
|
||||||
if (!prev) return newNode;
|
|
||||||
if (!targetId) {
|
|
||||||
return mergeAtRoot(prev, newNode, data.type === 'operator');
|
|
||||||
}
|
|
||||||
return addChild(prev, targetId, newNode);
|
|
||||||
});
|
|
||||||
}, [signalTab, def, onRootChange, onAddStart]);
|
|
||||||
|
|
||||||
const handleSectionDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverKey(null);
|
|
||||||
if (wasPaletteHtmlDropCommitted()) return;
|
|
||||||
const data = readPaletteHtmlDragData(e);
|
|
||||||
if (!data) return;
|
|
||||||
applyDrop(null, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => {
|
|
||||||
applyDrop(targetId, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stochPairForest = useMemo(() => [root], [root]);
|
const stochPairForest = useMemo(() => [root], [root]);
|
||||||
const handleStochPairSecondaryChange = useCallback((orNodeId: string, secondary: string) => {
|
const handleStochPairSecondaryChange = useCallback((orNodeId: string, secondary: string) => {
|
||||||
onRootChange(patchStochPairInTree(root, orNodeId, secondary, def));
|
onRootChange(prev => patchStochPairInTree(prev, orNodeId, secondary, def));
|
||||||
}, [root, def, onRootChange]);
|
}, [def, onRootChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="sp-start-section">
|
<section className="sp-start-section">
|
||||||
@@ -376,20 +310,9 @@ function StartSectionBlock({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`sp-start-body${dragOverKey === sectionDropKey ? ' sp-start-body--over' : ''}`}
|
className="sp-start-body"
|
||||||
data-palette-drop-key={sectionDropKey}
|
data-palette-drop-key={sectionDropKey}
|
||||||
onDragOver={e => {
|
onDragOver={allowPaletteDrop}
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverKey(sectionDropKey);
|
|
||||||
}}
|
|
||||||
onDragLeave={e => {
|
|
||||||
const rel = e.relatedTarget as Node | null;
|
|
||||||
if (!rel) return;
|
|
||||||
if (e.currentTarget.contains(rel)) return;
|
|
||||||
setDragOverKey(null);
|
|
||||||
}}
|
|
||||||
onDrop={handleSectionDrop}
|
|
||||||
>
|
>
|
||||||
{!root ? (
|
{!root ? (
|
||||||
<div className="sp-drop-empty sp-drop-empty--section">
|
<div className="sp-drop-empty sp-drop-empty--section">
|
||||||
@@ -399,14 +322,16 @@ function StartSectionBlock({
|
|||||||
<TreeNodeComp
|
<TreeNodeComp
|
||||||
node={root}
|
node={root}
|
||||||
signalType={signalTab}
|
signalType={signalTab}
|
||||||
onUpdate={onRootChange}
|
onUpdate={next => {
|
||||||
|
if (typeof next === 'function') {
|
||||||
|
onRootChange(prev => (prev ? next(prev) : prev));
|
||||||
|
} else {
|
||||||
|
onRootChange(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDelete={() => onRootChange(null)}
|
onDelete={() => onRootChange(null)}
|
||||||
onDropOnNode={handleDropOnNode}
|
|
||||||
dragOverKey={dragOverKey}
|
|
||||||
setDragOverKey={setDragOverKey}
|
|
||||||
dropScope={startId}
|
dropScope={startId}
|
||||||
def={def}
|
def={def}
|
||||||
onAddStart={onAddStart}
|
|
||||||
stochPairForest={stochPairForest}
|
stochPairForest={stochPairForest}
|
||||||
onStochPairSecondaryChange={handleStochPairSecondaryChange}
|
onStochPairSecondaryChange={handleStochPairSecondaryChange}
|
||||||
/>
|
/>
|
||||||
@@ -437,16 +362,8 @@ export default function StrategyListEditor({
|
|||||||
onOrphansChange,
|
onOrphansChange,
|
||||||
orphans = [],
|
orphans = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
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();
|
||||||
@@ -537,12 +454,22 @@ export default function StrategyListEditor({
|
|||||||
const applyDropRef = useRef(applyPaletteDropAtKey);
|
const applyDropRef = useRef(applyPaletteDropAtKey);
|
||||||
applyDropRef.current = applyPaletteDropAtKey;
|
applyDropRef.current = applyPaletteDropAtKey;
|
||||||
|
|
||||||
|
const schedulePaletteDrop = useCallback((x: number, y: number, payload: PaletteDragPayload) => {
|
||||||
|
const dropKey = findPaletteDropKeyAtPoint(x, y);
|
||||||
|
// dragend/DOM 교체 이후 한 프레임 뒤 적용 — dragenter 루프·이중 추가 방지
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
applyDropRef.current(dropKey, payload);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleDropRef = useRef(schedulePaletteDrop);
|
||||||
|
scheduleDropRef.current = schedulePaletteDrop;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (needsPointerPaletteDrag()) return undefined;
|
if (needsPointerPaletteDrag()) return undefined;
|
||||||
setHtmlPaletteDropRouter((x, y, payload) => {
|
setHtmlPaletteDropRouter((x, y, payload) => {
|
||||||
dragOverKeyRef.current = null;
|
clearPaletteDropHighlights();
|
||||||
setDragOverKey(null);
|
scheduleDropRef.current(x, y, payload);
|
||||||
applyDropRef.current(findPaletteDropKeyAtPoint(x, y), payload);
|
|
||||||
});
|
});
|
||||||
return () => setHtmlPaletteDropRouter(null);
|
return () => setHtmlPaletteDropRouter(null);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -551,54 +478,19 @@ export default function StrategyListEditor({
|
|||||||
if (!needsPointerPaletteDrag()) return undefined;
|
if (!needsPointerPaletteDrag()) return undefined;
|
||||||
setPaletteDragDropHandler(ev => {
|
setPaletteDragDropHandler(ev => {
|
||||||
if (ev.phase !== 'end') return;
|
if (ev.phase !== 'end') return;
|
||||||
dragOverKeyRef.current = null;
|
clearPaletteDropHighlights();
|
||||||
setDragOverKey(null);
|
scheduleDropRef.current(ev.x, ev.y, ev.payload);
|
||||||
applyDropRef.current(findPaletteDropKeyAtPoint(ev.x, ev.y), ev.payload);
|
|
||||||
});
|
});
|
||||||
return () => setPaletteDragDropHandler(null);
|
return () => setPaletteDragDropHandler(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => clearPaletteDropHighlights(), []);
|
||||||
return subscribePaletteDrag(ev => {
|
|
||||||
if (ev.phase === 'move') {
|
|
||||||
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';
|
|
||||||
setDragOverKeyThrottled(LIST_DROP_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleListDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setDragOverKey(null);
|
|
||||||
if (wasPaletteHtmlDropCommitted()) return;
|
|
||||||
const data = readPaletteHtmlDragData(e);
|
|
||||||
if (!data) return;
|
|
||||||
applyDropRef.current(findPaletteDropKeyAtPoint(e.clientX, e.clientY) ?? LIST_DROP_KEY, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`se-list-editor sp-dropzone${dragOverKey === LIST_DROP_KEY ? ' se-list-editor--over' : ''}`}
|
className="se-list-editor sp-dropzone"
|
||||||
data-palette-drop-key={LIST_DROP_KEY}
|
data-palette-drop-key={LIST_DROP_KEY}
|
||||||
onDragOver={handleListDragOver}
|
onDragOver={allowPaletteDrop}
|
||||||
onDragLeave={e => {
|
|
||||||
const rel = e.relatedTarget as Node | null;
|
|
||||||
if (!rel) return;
|
|
||||||
if (e.currentTarget.contains(rel)) return;
|
|
||||||
setDragOverKey(null);
|
|
||||||
}}
|
|
||||||
onDrop={handleListDrop}
|
|
||||||
>
|
>
|
||||||
{sections.map(section => (
|
{sections.map(section => (
|
||||||
<StartSectionBlock
|
<StartSectionBlock
|
||||||
@@ -609,12 +501,9 @@ export default function StrategyListEditor({
|
|||||||
canDelete={section.canDelete}
|
canDelete={section.canDelete}
|
||||||
signalTab={signalTab}
|
signalTab={signalTab}
|
||||||
def={def}
|
def={def}
|
||||||
dragOverKey={dragOverKey}
|
|
||||||
setDragOverKey={setDragOverKeyThrottled}
|
|
||||||
onRootChange={root => handleRootChange(section.startId, root)}
|
onRootChange={root => handleRootChange(section.startId, root)}
|
||||||
onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)}
|
onCandleTypesChange={types => handleCandleTypesChange(section.startId, types)}
|
||||||
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
onDeleteStart={section.canDelete ? () => handleDeleteStart(section.startId) : undefined}
|
||||||
onAddStart={handleAddStart}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{sections.length === 0 && (
|
{sections.length === 0 && (
|
||||||
|
|||||||
@@ -240,7 +240,8 @@
|
|||||||
padding-bottom: 28px;
|
padding-bottom: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.se-list-editor--over {
|
.se-list-editor--over,
|
||||||
|
.se-list-editor.sp-drag-over {
|
||||||
border-color: var(--se-node-start-border, var(--se-gold));
|
border-color: var(--se-node-start-border, var(--se-gold));
|
||||||
background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, var(--se-center-bg));
|
background: color-mix(in srgb, var(--se-node-start-accent, var(--se-gold)) 8%, var(--se-center-bg));
|
||||||
}
|
}
|
||||||
@@ -421,7 +422,8 @@
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.sp-start-body--over {
|
.sp-start-body--over,
|
||||||
|
.sp-start-body.sp-drag-over {
|
||||||
background: color-mix(in srgb, var(--se-accent) 10%, transparent);
|
background: color-mix(in srgb, var(--se-accent) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,30 @@ type HtmlPaletteDropRouter = (
|
|||||||
|
|
||||||
let htmlDropRouter: HtmlPaletteDropRouter | null = null;
|
let htmlDropRouter: HtmlPaletteDropRouter | null = null;
|
||||||
let htmlDropCommitted = false;
|
let htmlDropCommitted = false;
|
||||||
|
let paletteDropConsumed = false;
|
||||||
|
|
||||||
export function wasPaletteHtmlDropCommitted(): boolean {
|
export function wasPaletteHtmlDropCommitted(): boolean {
|
||||||
return htmlDropCommitted;
|
return htmlDropCommitted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 목록 편집기 등 — 드래그 하이라이트 클래스 일괄 제거 */
|
||||||
|
export function clearPaletteDropHighlights(): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
document.querySelectorAll('.sp-drag-over').forEach(el => {
|
||||||
|
el.classList.remove('sp-drag-over');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPaletteDropConsumed(): void {
|
||||||
|
paletteDropConsumed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryConsumePaletteDrop(): boolean {
|
||||||
|
if (paletteDropConsumed) return false;
|
||||||
|
paletteDropConsumed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function setHtmlPaletteDropRouter(router: HtmlPaletteDropRouter | null): void {
|
export function setHtmlPaletteDropRouter(router: HtmlPaletteDropRouter | null): void {
|
||||||
htmlDropRouter = router;
|
htmlDropRouter = router;
|
||||||
}
|
}
|
||||||
@@ -180,10 +199,15 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) {
|
|||||||
|
|
||||||
if (phase === 'end') {
|
if (phase === 'end') {
|
||||||
suppressClickUntil = Date.now() + 400;
|
suppressClickUntil = Date.now() + 400;
|
||||||
dispatchEnd(dropXY, payload);
|
if (tryConsumePaletteDrop()) {
|
||||||
|
dispatchEnd(dropXY, payload);
|
||||||
|
} else {
|
||||||
|
emit({ phase: 'end', x: dropXY.x, y: dropXY.y, payload });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
emit({ phase: 'cancel', x: dropXY.x, y: dropXY.y, payload });
|
emit({ phase: 'cancel', x: dropXY.x, y: dropXY.y, payload });
|
||||||
}
|
}
|
||||||
|
clearPaletteDropHighlights();
|
||||||
cleanupDrag();
|
cleanupDrag();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +221,8 @@ function mountDragOverlay(
|
|||||||
activePayload = payload;
|
activePayload = payload;
|
||||||
activePointerId = pointerId;
|
activePointerId = pointerId;
|
||||||
lastDragClientXY = { x: xy.x, y: xy.y };
|
lastDragClientXY = { x: xy.x, y: xy.y };
|
||||||
|
resetPaletteDropConsumed();
|
||||||
|
clearPaletteDropHighlights();
|
||||||
document.body.classList.add('se-palette-drag-active');
|
document.body.classList.add('se-palette-drag-active');
|
||||||
armPaletteDragScrollLock();
|
armPaletteDragScrollLock();
|
||||||
overlayController?.mount({
|
overlayController?.mount({
|
||||||
@@ -375,9 +401,12 @@ function installHtmlPaletteDragGlobals() {
|
|||||||
const payload = activeHtmlDragPayload;
|
const payload = activeHtmlDragPayload;
|
||||||
const xy = lastHtmlDragClientXY;
|
const xy = lastHtmlDragClientXY;
|
||||||
if (htmlPaletteDragActive && !htmlDropCommitted && payload && xy && htmlDropRouter) {
|
if (htmlPaletteDragActive && !htmlDropCommitted && payload && xy && htmlDropRouter) {
|
||||||
htmlDropRouter(xy.x, xy.y, payload);
|
if (tryConsumePaletteDrop()) {
|
||||||
|
htmlDropRouter(xy.x, xy.y, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
htmlDropCommitted = false;
|
htmlDropCommitted = false;
|
||||||
|
clearPaletteDropHighlights();
|
||||||
stopHtmlDragMouseTracking();
|
stopHtmlDragMouseTracking();
|
||||||
htmlPaletteDragActive = false;
|
htmlPaletteDragActive = false;
|
||||||
lastHtmlDragClientXY = null;
|
lastHtmlDragClientXY = null;
|
||||||
@@ -390,9 +419,11 @@ function installHtmlPaletteDragGlobals() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const payload = readPaletteHtmlDragDataFromEvent(e) ?? activeHtmlDragPayload;
|
const payload = readPaletteHtmlDragDataFromEvent(e) ?? activeHtmlDragPayload;
|
||||||
if (!payload || !htmlDropRouter || htmlDropCommitted) return;
|
if (!payload || !htmlDropRouter || htmlDropCommitted) return;
|
||||||
|
if (!tryConsumePaletteDrop()) return;
|
||||||
htmlDropCommitted = true;
|
htmlDropCommitted = true;
|
||||||
const x = e.clientX !== 0 || e.clientY !== 0 ? e.clientX : (lastHtmlDragClientXY?.x ?? 0);
|
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);
|
const y = e.clientX !== 0 || e.clientY !== 0 ? e.clientY : (lastHtmlDragClientXY?.y ?? 0);
|
||||||
|
clearPaletteDropHighlights();
|
||||||
htmlDropRouter(x, y, payload);
|
htmlDropRouter(x, y, payload);
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
@@ -432,6 +463,8 @@ export function endHtmlDragMouseTracking(): void {
|
|||||||
export function markPaletteHtmlDragStart(): void {
|
export function markPaletteHtmlDragStart(): void {
|
||||||
htmlPaletteDragActive = true;
|
htmlPaletteDragActive = true;
|
||||||
htmlDropCommitted = false;
|
htmlDropCommitted = false;
|
||||||
|
resetPaletteDropConsumed();
|
||||||
|
clearPaletteDropHighlights();
|
||||||
lastHtmlDragClientXY = null;
|
lastHtmlDragClientXY = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,15 +517,16 @@ 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 hosts = Array.from(document.querySelectorAll<HTMLElement>('[data-palette-drop-key]'));
|
const hosts = Array.from(document.querySelectorAll<HTMLElement>('[data-palette-drop-key]'));
|
||||||
for (let i = hosts.length - 1; i >= 0; i--) {
|
let best: { key: string; area: number } | null = null;
|
||||||
const host = hosts[i];
|
for (const host of hosts) {
|
||||||
const key = host.dataset.paletteDropKey;
|
const key = host.dataset.paletteDropKey;
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const rect = host.getBoundingClientRect();
|
const rect = host.getBoundingClientRect();
|
||||||
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) continue;
|
||||||
return key;
|
const area = rect.width * rect.height;
|
||||||
}
|
if (!best || area < best.area) best = { key, area };
|
||||||
}
|
}
|
||||||
|
if (best) return best.key;
|
||||||
for (const el of elementsUnderDragPoint(x, y)) {
|
for (const el of elementsUnderDragPoint(x, y)) {
|
||||||
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;
|
if (host?.dataset.paletteDropKey) return host.dataset.paletteDropKey;
|
||||||
|
|||||||
Reference in New Issue
Block a user