전략편집기 목록방식 추가오류 수정

This commit is contained in:
Macbook
2026-06-18 08:58:54 +09:00
parent bde65b6ef8
commit 914afc9d5a
5 changed files with 121 additions and 187 deletions
+2 -1
View File
@@ -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 && (
+4 -2
View File
@@ -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);
} }
+41 -7
View File
@@ -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;