전략편집기 횡보구간 컨트롤 추가
This commit is contained in:
@@ -45,6 +45,8 @@ import {
|
||||
type StartCombineOp,
|
||||
} from '../utils/strategyStartNodes';
|
||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
|
||||
import { buildSidewaysFilterNode } from '../utils/sidewaysFilterPalette';
|
||||
import {
|
||||
loadPaletteItems,
|
||||
type PaletteItem,
|
||||
@@ -99,7 +101,16 @@ import {
|
||||
strategyDtoToListExportItem,
|
||||
} from '../utils/strategyImportExport';
|
||||
import PaletteChip from './strategyEditor/PaletteChip';
|
||||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
|
||||
import {
|
||||
clampPanelSize,
|
||||
readStoredBool,
|
||||
readStoredSize,
|
||||
storeBool,
|
||||
storeSize,
|
||||
usePanelResize,
|
||||
useRightPanelResize,
|
||||
} from './strategyEditor/usePanelResize';
|
||||
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||
@@ -120,10 +131,21 @@ const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
|
||||
const LEFT_PANEL_MIN = 220;
|
||||
const LEFT_PANEL_MAX = 520;
|
||||
const LEFT_PANEL_DEFAULT = 280;
|
||||
const RIGHT_PANEL_MIN = 380;
|
||||
const RIGHT_PANEL_MAX = 560;
|
||||
const RIGHT_PANEL_DEFAULT = 380;
|
||||
const TERMINAL_MIN = 88;
|
||||
const TERMINAL_MAX = 420;
|
||||
const TERMINAL_DEFAULT = 140;
|
||||
|
||||
function readRightPanelWidth(): number {
|
||||
return clampPanelSize(
|
||||
readStoredSize('se-right-width', RIGHT_PANEL_DEFAULT),
|
||||
RIGHT_PANEL_MIN,
|
||||
RIGHT_PANEL_MAX,
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onNavigateToBacktest?: () => void;
|
||||
@@ -189,7 +211,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||||
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
|
||||
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite'>('auxiliary');
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
|
||||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
@@ -224,8 +246,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||||
const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth());
|
||||
const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true));
|
||||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||||
const leftWidthRef = useRef(leftWidth);
|
||||
const rightWidthRef = useRef(rightWidth);
|
||||
const terminalHeightRef = useRef(terminalHeight);
|
||||
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||||
@@ -246,6 +271,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const layoutPersistReadyRef = useRef(false);
|
||||
const STRATEGY_AUTOSAVE_MS = 350;
|
||||
leftWidthRef.current = leftWidth;
|
||||
rightWidthRef.current = rightWidth;
|
||||
terminalHeightRef.current = terminalHeight;
|
||||
|
||||
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
||||
@@ -260,6 +286,22 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
v => storeSize('se-left-width', v),
|
||||
);
|
||||
|
||||
const onRightSplitter = useRightPanelResize(
|
||||
setRightWidth,
|
||||
() => rightWidthRef.current,
|
||||
RIGHT_PANEL_MIN,
|
||||
RIGHT_PANEL_MAX,
|
||||
v => storeSize('se-right-width', v),
|
||||
);
|
||||
|
||||
const toggleRightPanel = useCallback(() => {
|
||||
setRightOpen(prev => {
|
||||
const next = !prev;
|
||||
storeBool('se-right-open', next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onTerminalSplitter = usePanelResize(
|
||||
'horizontal',
|
||||
setTerminalHeight,
|
||||
@@ -271,8 +313,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
|
||||
const bodyStyle = useMemo(() => ({
|
||||
'--se-left-width': `${leftWidth}px`,
|
||||
'--se-right-width': `${rightWidth}px`,
|
||||
'--se-terminal-height': `${terminalHeight}px`,
|
||||
}) as React.CSSProperties, [leftWidth, terminalHeight]);
|
||||
}) as React.CSSProperties, [leftWidth, rightWidth, terminalHeight]);
|
||||
|
||||
const showSnack = (msg: string, ok = true) => {
|
||||
setSnack({ msg, ok });
|
||||
@@ -1021,6 +1064,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
scheduleStrategyPersist();
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const applySidewaysFilter = useCallback((filterId: string) => {
|
||||
const newNode = buildSidewaysFilterNode(filterId, DEF);
|
||||
if (!newNode) return;
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
scheduleStrategyPersist();
|
||||
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||
|
||||
const templateRows = useMemo(() => {
|
||||
@@ -1645,7 +1697,24 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="se-right">
|
||||
{rightOpen && (
|
||||
<div
|
||||
className="se-splitter se-splitter--v"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="우측 패널 너비 조절"
|
||||
onPointerDown={onRightSplitter}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`se-side-wrap se-side-wrap--right${rightOpen ? ' se-side-wrap--open' : ''}`}>
|
||||
<SePanelCollapseHandle
|
||||
side="right"
|
||||
open={rightOpen}
|
||||
onToggle={toggleRightPanel}
|
||||
label="지표 패널"
|
||||
/>
|
||||
<aside className={`se-right se-right--collapsible${rightOpen ? ' se-right--collapsible--open' : ''}`}>
|
||||
<div className="se-palette-panel">
|
||||
<div className="se-right-tabs">
|
||||
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
|
||||
@@ -1702,7 +1771,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
<div className="se-palette-subtabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
|
||||
className={`se-palette-subtab se-palette-subtab--aux${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('auxiliary');
|
||||
setSelectedPaletteKey(null);
|
||||
@@ -1712,7 +1781,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
|
||||
className={`se-palette-subtab se-palette-subtab--composite${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('composite');
|
||||
setSelectedPaletteKey(null);
|
||||
@@ -1720,6 +1789,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
>
|
||||
복합지표
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`se-palette-subtab se-palette-subtab--range${indicatorSubTab === 'range' ? ' se-palette-subtab--on' : ''}`}
|
||||
onClick={() => {
|
||||
setIndicatorSubTab('range');
|
||||
setSelectedPaletteKey(null);
|
||||
}}
|
||||
>
|
||||
횡보필터
|
||||
</button>
|
||||
</div>
|
||||
{indicatorSubTab === 'auxiliary' ? (
|
||||
<IndicatorPaletteTab
|
||||
@@ -1739,7 +1818,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
applyPaletteItem(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : indicatorSubTab === 'composite' ? (
|
||||
<IndicatorPaletteTab
|
||||
kind="composite"
|
||||
items={compositePalette}
|
||||
@@ -1757,6 +1836,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
applyPaletteItem(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SidewaysFilterPaletteTab
|
||||
def={DEF}
|
||||
searchQuery={paletteSearch}
|
||||
selectedItemId={
|
||||
selectedPaletteKey?.startsWith('range:')
|
||||
? selectedPaletteKey.slice('range:'.length)
|
||||
: null
|
||||
}
|
||||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('range', id) : null)}
|
||||
onAddToCanvas={item => {
|
||||
selectPalette('range', item.id);
|
||||
applySidewaysFilter(item.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1821,6 +1915,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
side: 'left' | 'right';
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 전략편집기 좌·우 패널 접기 핸들 */
|
||||
export default function SePanelCollapseHandle({ side, open, onToggle, label }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`se-panel-handle se-panel-handle--${side}${open ? ' se-panel-handle--open' : ''}`}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
title={open ? `${label} 닫기` : `${label} 열기`}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `${label} 닫기` : `${label} 열기`}
|
||||
>
|
||||
<svg
|
||||
width="8"
|
||||
height="14"
|
||||
viewBox="0 0 8 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{side === 'left' ? (
|
||||
open ? <polyline points="6,1 2,7 6,13" /> : <polyline points="2,1 6,7 2,13" />
|
||||
) : (
|
||||
open ? <polyline points="2,1 6,7 2,13" /> : <polyline points="6,1 2,7 6,13" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { DefType } from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
DEFAULT_SIDEWAYS_FILTER_ITEMS,
|
||||
filterSidewaysItems,
|
||||
type SidewaysFilterItem,
|
||||
type SidewaysFilterMode,
|
||||
} from '../../utils/sidewaysFilterPalette';
|
||||
|
||||
export interface SidewaysFilterDragPayload {
|
||||
type: 'sidewaysFilter';
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
def: DefType;
|
||||
searchQuery: string;
|
||||
selectedItemId: string | null;
|
||||
onSelectItem: (id: string | null) => void;
|
||||
onAddToCanvas: (item: SidewaysFilterItem) => void;
|
||||
}
|
||||
|
||||
function modeIcon(mode: SidewaysFilterMode): { glyph: string; cls: string; aria: string } {
|
||||
return mode === 'include'
|
||||
? { glyph: '▣', cls: 'se-range-chip-icon--include', aria: '횡보' }
|
||||
: { glyph: '⊘', cls: 'se-range-chip-icon--exclude', aria: '제외' };
|
||||
}
|
||||
|
||||
interface HoverHintState {
|
||||
label: string;
|
||||
desc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function SidewaysFilterChip({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onHintChange,
|
||||
}: {
|
||||
item: SidewaysFilterItem;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onAdd: () => void;
|
||||
onHintChange: (hint: HoverHintState | null) => void;
|
||||
}) {
|
||||
const rightIcon = modeIcon(item.mode);
|
||||
|
||||
const showHint = useCallback((el: HTMLElement) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
onHintChange({
|
||||
label: item.label,
|
||||
desc: item.desc,
|
||||
x: r.left + r.width / 2,
|
||||
y: r.top,
|
||||
});
|
||||
}, [item.label, item.desc, onHintChange]);
|
||||
|
||||
const hideHint = useCallback(() => onHintChange(null), [onHintChange]);
|
||||
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
hideHint();
|
||||
const payload: SidewaysFilterDragPayload = {
|
||||
type: 'sidewaysFilter',
|
||||
value: item.id,
|
||||
label: item.label,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const handleClick = () => onSelect();
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (selected) onAdd();
|
||||
else onSelect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`se-palette-card se-palette-card--range se-range-chip${selected ? ' se-palette-card--selected' : ''}`}
|
||||
onDragStart={onDragStart}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={e => showHint(e.currentTarget)}
|
||||
onMouseLeave={hideHint}
|
||||
onFocus={e => showHint(e.currentTarget)}
|
||||
onBlur={hideHint}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<div className="se-range-chip-icons">
|
||||
<span className="se-range-chip-icon se-range-chip-icon--left" aria-hidden>↔</span>
|
||||
<span
|
||||
className={`se-range-chip-icon se-range-chip-icon--right ${rightIcon.cls}`}
|
||||
aria-label={rightIcon.aria}
|
||||
title=""
|
||||
>
|
||||
{rightIcon.glyph}
|
||||
</span>
|
||||
</div>
|
||||
<span className="se-range-chip-name">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidewaysFilterHintTooltip({ hint }: { hint: HoverHintState }) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="se-range-hint-tooltip"
|
||||
role="tooltip"
|
||||
style={{
|
||||
left: hint.x,
|
||||
top: hint.y,
|
||||
}}
|
||||
>
|
||||
<div className="se-range-hint-tooltip-title">{hint.label}</div>
|
||||
<div className="se-range-hint-tooltip-desc">{hint.desc}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default function SidewaysFilterPaletteTab({
|
||||
def,
|
||||
searchQuery,
|
||||
selectedItemId,
|
||||
onSelectItem,
|
||||
onAddToCanvas,
|
||||
}: Props) {
|
||||
void def;
|
||||
const [modeFilter, setModeFilter] = useState<'all' | SidewaysFilterMode>('all');
|
||||
const [hoverHint, setHoverHint] = useState<HoverHintState | null>(null);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterSidewaysItems(DEFAULT_SIDEWAYS_FILTER_ITEMS, searchQuery, modeFilter),
|
||||
[searchQuery, modeFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="se-palette-section se-palette-section--range se-palette-section--scroll">
|
||||
{hoverHint && <SidewaysFilterHintTooltip hint={hoverHint} />}
|
||||
<div className="se-palette-toolbar">
|
||||
<span className="se-palette-toolbar-title">횡보 필터</span>
|
||||
<span className="se-range-count">{filtered.length}개</span>
|
||||
</div>
|
||||
|
||||
<div className="se-range-mode-tabs" role="tablist" aria-label="횡보 필터 모드">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={modeFilter === 'all'}
|
||||
className={`se-range-mode-tab${modeFilter === 'all' ? ' se-range-mode-tab--on' : ''}`}
|
||||
onClick={() => setModeFilter('all')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={modeFilter === 'include'}
|
||||
className={`se-range-mode-tab se-range-mode-tab--include${modeFilter === 'include' ? ' se-range-mode-tab--on' : ''}`}
|
||||
onClick={() => setModeFilter('include')}
|
||||
>
|
||||
횡보만
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={modeFilter === 'exclude'}
|
||||
className={`se-range-mode-tab se-range-mode-tab--exclude${modeFilter === 'exclude' ? ' se-range-mode-tab--on' : ''}`}
|
||||
onClick={() => setModeFilter('exclude')}
|
||||
>
|
||||
횡보 제외
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="se-palette-grid se-palette-grid--3 se-range-grid">
|
||||
{filtered.map(item => (
|
||||
<SidewaysFilterChip
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={selectedItemId === item.id}
|
||||
onSelect={() => onSelectItem(item.id)}
|
||||
onAdd={() => onAddToCanvas(item)}
|
||||
onHintChange={setHoverHint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="se-palette-empty">검색 결과가 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
updateNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPalette';
|
||||
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
@@ -563,18 +564,28 @@ function StrategyEditorCanvasInner({
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const resolvePaletteDropNode = useCallback((
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
): LogicNode | null => {
|
||||
if (data.type === 'sidewaysFilter') {
|
||||
return buildSidewaysFilterNode(data.value, def);
|
||||
}
|
||||
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
}, [signalTab, def]);
|
||||
|
||||
const addOrphanAt = useCallback((
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
const newNode = resolvePaletteDropNode(data);
|
||||
if (!newNode) return;
|
||||
positionsRef.current.set(newNode.id, {
|
||||
x: flowPos.x - FLOW_NODE_W / 2,
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
});
|
||||
onOrphansChange([...orphans, newNode]);
|
||||
scheduleLayoutEmit();
|
||||
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
|
||||
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
@@ -586,7 +597,8 @@ function StrategyEditorCanvasInner({
|
||||
return;
|
||||
}
|
||||
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
const newNode = resolvePaletteDropNode(data);
|
||||
if (!newNode) return;
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
@@ -636,7 +648,7 @@ function StrategyEditorCanvasInner({
|
||||
}
|
||||
saveAttachHandles(anchorId);
|
||||
scheduleLayoutEmit();
|
||||
}, [root, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
|
||||
}, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
|
||||
|
||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
updateDropPreview(anchorId, flowPos);
|
||||
|
||||
@@ -48,6 +48,49 @@ export function usePanelResize(
|
||||
}, [axis, onResize, getCurrent, min, max, onCommit]);
|
||||
}
|
||||
|
||||
/** 우측 패널 — 스플리터를 왼쪽으로 드래그하면 넓어짐 */
|
||||
export function useRightPanelResize(
|
||||
onResize: (next: number) => void,
|
||||
getCurrent: () => number,
|
||||
min: number,
|
||||
max: number,
|
||||
onCommit?: (value: number) => void,
|
||||
) {
|
||||
return useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const start = getCurrent();
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.add('se-splitter--active');
|
||||
const splitter = e.currentTarget;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
onResize(clamp(start - delta, min, max));
|
||||
};
|
||||
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
splitter.releasePointerCapture(ev.pointerId);
|
||||
splitter.classList.remove('se-splitter--active');
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
onCommit?.(getCurrent());
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}, [onResize, getCurrent, min, max, onCommit]);
|
||||
}
|
||||
|
||||
export function clampPanelSize(value: number, min: number, max: number): number {
|
||||
return clamp(value, min, max);
|
||||
}
|
||||
|
||||
export function readStoredSize(key: string, fallback: number): number {
|
||||
try {
|
||||
const raw = getUiPreferences().panels?.[key];
|
||||
|
||||
Reference in New Issue
Block a user