패널 줄이 가로 스크롤 상자(overflow-x: auto)라, CSS 규칙상 세로도 auto로 계산되어 슬라이드아웃 메뉴가 리본 높이에서 잘렸다. 캔버스에 가려 보이는 증상이 이것이다. 메뉴를 화면 좌표(position: fixed)로 띄우고 라벨 버튼의 위치에서 자리를 잡는다. 오른쪽·아래로 넘치지 않게 좌표를 물리고 높이를 72vh로 제한했다. 캔버스 위에 뜨게 됐으므로 바깥을 누르면 닫히고, 다른 패널을 열면 먼저 열린 것이 닫힌다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
184 lines
5.5 KiB
TypeScript
184 lines
5.5 KiB
TypeScript
/** 리본 렌더러 — ribbon.config.ts의 데이터와 명령 레지스트리만 보고 그린다. */
|
|
import { type FC, type ReactNode, useEffect, useRef, useState } from 'react';
|
|
import type { CadCommand } from '../commands/command.types';
|
|
import { getCommandById } from '../commands/registry';
|
|
import { runCommand } from '../commands/run-command';
|
|
import { getActiveRibbonTab, setActiveRibbonTab } from '../components/ui-state';
|
|
import type { Tool } from '../tools';
|
|
import { RIBBON_TABS, type RibbonPanel, type RibbonWidget } from './ribbon.config';
|
|
|
|
interface RibbonProps {
|
|
activeTool: Tool | null;
|
|
widgets: Partial<Record<RibbonWidget, ReactNode>>;
|
|
onCommandRun?: (command: CadCommand) => void;
|
|
}
|
|
|
|
interface CommandButtonProps {
|
|
command: CadCommand;
|
|
size: 'big' | 'small';
|
|
activeTool: Tool | null;
|
|
onRun: (command: CadCommand) => void;
|
|
}
|
|
|
|
const CommandButton: FC<CommandButtonProps> = ({ command, size, activeTool, onRun }) => (
|
|
<button
|
|
type="button"
|
|
className="cad-tool"
|
|
data-size={size}
|
|
data-active={!!command.tool && command.tool === activeTool}
|
|
title={`${command.label}${command.aliases?.length ? ` (${command.aliases[0]})` : ''}${
|
|
command.hint ? ` — ${command.hint}` : ''
|
|
}`}
|
|
onClick={() => onRun(command)}
|
|
>
|
|
<span className="cad-tool__glyph">{command.glyph}</span>
|
|
<span className="cad-tool__label">{command.label}</span>
|
|
</button>
|
|
);
|
|
|
|
/** 슬라이드아웃 최소 폭 — 화면 오른쪽으로 넘치지 않게 자리를 잡을 때 쓴다 */
|
|
const OVERFLOW_WIDTH = 170;
|
|
|
|
const resolve = (ids: string[] | undefined): CadCommand[] =>
|
|
(ids ?? []).map((id) => getCommandById(id)).filter((command): command is CadCommand => !!command);
|
|
|
|
const Panel: FC<{
|
|
panel: RibbonPanel;
|
|
activeTool: Tool | null;
|
|
widgets: Partial<Record<RibbonWidget, ReactNode>>;
|
|
onRun: (command: CadCommand) => void;
|
|
}> = ({ panel, activeTool, widgets, onRun }) => {
|
|
// 패널 슬라이드아웃은 화면 좌표로 띄운다 — 리본 패널 줄이 가로 스크롤 상자라
|
|
// 그 안에 두면 리본 높이에서 잘려 캔버스에 가린 것처럼 보인다.
|
|
const labelRef = useRef<HTMLButtonElement>(null);
|
|
const [overflowAt, setOverflowAt] = useState<{ top: number; left: number } | null>(null);
|
|
const overflowCommands = resolve(panel.overflow);
|
|
|
|
// 캔버스 위에 뜨는 메뉴라 다른 곳을 누르면 닫는다
|
|
useEffect(() => {
|
|
if (!overflowAt) return;
|
|
const close = (event: PointerEvent) => {
|
|
const target = event.target as Node | null;
|
|
// 자기 메뉴·자기 라벨만 예외다 — 다른 패널을 누르면 이 메뉴는 닫힌다
|
|
if (target instanceof HTMLElement && target.closest('.cad-ribbon-overflow')) return;
|
|
if (target && labelRef.current?.contains(target)) return;
|
|
setOverflowAt(null);
|
|
};
|
|
document.addEventListener('pointerdown', close);
|
|
return () => document.removeEventListener('pointerdown', close);
|
|
}, [overflowAt]);
|
|
|
|
const toggleOverflow = () => {
|
|
if (!overflowCommands.length) return;
|
|
if (overflowAt) {
|
|
setOverflowAt(null);
|
|
return;
|
|
}
|
|
const rect = labelRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
setOverflowAt({
|
|
top: rect.bottom + 2,
|
|
left: Math.max(4, Math.min(rect.left, window.innerWidth - OVERFLOW_WIDTH - 8)),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section className="cad-ribbon-group">
|
|
<div className="cad-ribbon-tools">
|
|
{panel.widget ? (
|
|
(widgets[panel.widget] ?? null)
|
|
) : (
|
|
<>
|
|
{resolve(panel.big).map((command) => (
|
|
<CommandButton
|
|
key={command.id}
|
|
command={command}
|
|
size="big"
|
|
activeTool={activeTool}
|
|
onRun={onRun}
|
|
/>
|
|
))}
|
|
<div className="cad-ribbon-tools__grid">
|
|
{resolve(panel.commands).map((command) => (
|
|
<CommandButton
|
|
key={command.id}
|
|
command={command}
|
|
size="small"
|
|
activeTool={activeTool}
|
|
onRun={onRun}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<button
|
|
ref={labelRef}
|
|
type="button"
|
|
className="cad-ribbon-group__label"
|
|
data-expandable={overflowCommands.length > 0}
|
|
onClick={toggleOverflow}
|
|
>
|
|
{panel.label}
|
|
{overflowCommands.length ? ' ▾' : ''}
|
|
</button>
|
|
{overflowAt && overflowCommands.length > 0 && (
|
|
<div className="cad-ribbon-overflow" style={{ top: overflowAt.top, left: overflowAt.left }}>
|
|
{overflowCommands.map((command) => (
|
|
<button
|
|
type="button"
|
|
key={command.id}
|
|
title={command.hint}
|
|
onClick={() => {
|
|
setOverflowAt(null);
|
|
onRun(command);
|
|
}}
|
|
>
|
|
<span className="cad-tool__glyph">{command.glyph}</span>
|
|
{command.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export const Ribbon: FC<RibbonProps> = ({ activeTool, widgets, onCommandRun }) => {
|
|
const activeTabId = getActiveRibbonTab();
|
|
const activeTab = RIBBON_TABS.find((tab) => tab.id === activeTabId) ?? RIBBON_TABS[0];
|
|
|
|
const handleRun = (command: CadCommand) => {
|
|
runCommand(command);
|
|
onCommandRun?.(command);
|
|
};
|
|
|
|
return (
|
|
<nav className="cad-ribbon controls" aria-label="CAD 리본">
|
|
<div className="cad-ribbon-tabs">
|
|
{RIBBON_TABS.map((tab) => (
|
|
<button
|
|
type="button"
|
|
key={tab.id}
|
|
data-active={tab.id === activeTab.id}
|
|
onClick={() => setActiveRibbonTab(tab.id)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="cad-ribbon-panels">
|
|
{activeTab.panels.map((panel) => (
|
|
<Panel
|
|
key={panel.label}
|
|
panel={panel}
|
|
activeTool={activeTool}
|
|
widgets={widgets}
|
|
onRun={handleRun}
|
|
/>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|