import { useRef, useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" import { withSWNotice } from "./sw-story-notice" import { SWHeader, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerMenu, SWViewerPanel, SWViewerSection, type SWViewerHeaderGroup, type SWViewerMenuEntry, } from "@hm-design/sw" import "@hm-design/sw/styles.css" import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search } from "lucide-react" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { showVerificationControls } from "./sw-story-flags" import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, VIEWER_STORY_LOGO, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture" import "./sw-stories.css" import "./sw-viewer-stories.css" import placeholderLogo from "./assets/sw-header-logo-placeholder.svg" // SW viewer mode (2026-10-19): the reference DWG/DXF viewer top menu and popups as package components. // Stories use the public @hm-design/sw API only, like a consuming program. No DWG/DXF engine: commands are logged. const KICKER = "HM DESIGN SYSTEM / SW" const meta = { title: "SW/Viewer", parameters: { layout: "fullscreen", controls: { disable: true } }, decorators: [withSWNotice], } satisfies Meta export default meta type Story = StoryObj // Command record for the browser tests only (2026-10-21 Storybook simplification: not shown on the page). function Log({ lines }: { lines: string[] }) { return } /** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */ function ViewerFrame({ width = 1440, height = 620, children, header }: { width?: number | null; height?: number; header: ReactNode; children?: ReactNode }) { return (
{/* Fills the Story column up to `width` (null: the whole column), so panels placed at the right edge stay visible. */}
{header}
{children}
) } function AboutPanel({ demo }: { demo: ViewerDemo }) { return ( demo.setAboutOpen(false)} width={420} height="auto" resizable={false} collapsible={false} footer={ demo.setAboutOpen(false)}>확인}>
  1. 파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)
  2. CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)
  3. Three.js 씬 구성 및 렌더링
Storybook 예시: 링크와 실제 고지문은 프로그램이 제공합니다.
) } // 2026-10-22 (user request): the Viewer Header screens have no 1440px maximum; they fill the Story column. function ViewerScreen({ width = null as number | null, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[] }) { const demo = useViewerDemo({ initialDocument, initialPanels: open }) return ( <> }> ) } export const ViewerHeader: Story = { name: "Viewer Header", render: () => ( {/* The minimize / close example was removed from this Story; the component keeps supporting it (SWHeaderWindowControls at the right end of `trailing`, VIEWER.md §2.1). */} ), } export const MenuGroups: Story = { name: "Menu Groups", // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks. tags: ["verification"], render: () => { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) const pick = (ids: string[]): SWViewerHeaderGroup[] => demo.groups.map(group => ({ ...group, hidden: group.hidden || !ids.includes(group.id) })) const rows: { title: string; ids: string[] }[] = [ { title: "파일 그룹만 (파일 열기 · 샘플)", ids: ["file"] }, { title: "도면 공간 (field 면 · tabs)", ids: ["space"] }, { title: "화면 보기 (field 면 · button + switch)", ids: ["view"] }, { title: "오른쪽 영역 (placement=end 패널 토글 + trailing 상태 · 정보)", ids: ["panels"] }, { title: "전체", ids: demo.groups.map(group => group.id) }, ] return (
{rows.map(row => (
))}
) }, } export const States: Story = { name: "Item States", // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks. tags: ["verification"], render: () => { const [log, setLog] = useState([]) const record = (line: string) => setLog(lines => [...lines.slice(-7), line]) const groups: SWViewerHeaderGroup[] = [ { id: "states", label: "상태 예시", items: [ { type: "button", id: "default", label: "기본", icon: }, { type: "toggle", id: "active", label: "Active (on)", icon: , pressed: true }, { type: "toggle", id: "inactive", label: "Toggle (off)", icon: , pressed: false }, { type: "button", id: "disabled", label: "Disabled", icon: , disabled: true }, { type: "menu", id: "menu", label: "Dropdown", entries: [{ type: "item", id: "a", label: "항목 A" }, { type: "item", id: "b", label: "항목 B (disabled)", disabled: true }] }, { type: "menu", id: "menu-disabled", label: "Dropdown (disabled)", disabled: true, entries: [] }, ], }, // 2026-10-23: the Model / Layout tabs get a field group of their own, as in the viewer demo, so the Story shows // their width rule (equal buttons, the field face up to 378px with room). { id: "tabs", label: "탭", surface: "field", items: [ { type: "tabs", id: "tabs", label: "탭", value: "model", options: [{ value: "model", label: "Model", count: 17377 }, { value: "layout", label: "Layout", count: 237 }, { value: "off", label: "Disabled", disabled: true }] }, ], }, { id: "field", label: "field 면", surface: "field", items: [ { type: "switch", id: "on", label: "Switch on", checked: true }, { type: "switch", id: "off", label: "Switch off", checked: false }, { type: "switch", id: "sw-disabled", label: "Disabled", checked: false, disabled: true }, ], }, ] return (
record(`${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`)} />
) }, } const MENU_ENTRIES: SWViewerMenuEntry[] = [ { type: "label", id: "l-file", label: "파일" }, { type: "item", id: "open", label: "파일 열기", icon: , shortcut: "Ctrl+O" }, { type: "submenu", id: "recent", label: "최근 도면", icon: , entries: [ ...SAMPLES.map(sample => ({ type: "item" as const, id: `recent:${sample.id}`, label: sample.name })), { type: "separator", id: "s-recent" }, { type: "submenu", id: "recent-more", label: "보관 도면", entries: [{ type: "item", id: "archive:2025", label: "2025-평면도.dwg" }, { type: "item", id: "archive:2024", label: "2024-종단면도.dwg" }] }, ], }, { type: "item", id: "export", label: "PNG로 내보내기", icon: }, { type: "item", id: "print", label: "인쇄 (미연결)", icon: , disabled: true }, { type: "separator", id: "s1" }, { type: "label", id: "l-view", label: "화면 보기" }, { type: "checkbox", id: "grid", label: "그리드", checked: true }, { type: "checkbox", id: "lineweight", label: "선가중치 표시", checked: false }, { type: "separator", id: "s2" }, { type: "label", id: "l-unit", label: "단위" }, { type: "radio", id: "unit:mm", label: "mm", checked: true }, { type: "radio", id: "unit:m", label: "m", checked: false }, ] export const Dropdowns: Story = { name: "Header + Dropdown", // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks. tags: ["verification"], render: () => { const [entries, setEntries] = useState(MENU_ENTRIES) const [log, setLog] = useState([]) const record = (line: string) => setLog(lines => [...lines.slice(-7), line]) const apply = (entry: SWViewerMenuEntry) => setEntries(list => list.map(item => { if (entry.type === "checkbox" && item.id === entry.id && item.type === "checkbox") return { ...item, checked: !item.checked } if (entry.type === "radio" && item.type === "radio" && item.id.startsWith("unit:")) return { ...item, checked: item.id === entry.id } return item })) const groups: SWViewerHeaderGroup[] = [ { id: "file", label: "파일", items: [{ type: "menu", id: "file", label: "도면", icon: , entries: entries.map(entry => ({ ...entry, ...(entry.type === "checkbox" || entry.type === "radio" ? { keepOpen: false } : {}) })) }] }, { id: "tools", label: "도구", items: [ { type: "menu", id: "measure", label: "측정", icon: , entries: [{ type: "item", id: "distance", label: "거리" }, { type: "item", id: "area", label: "면적" }, { type: "submenu", id: "angle", label: "각도", entries: [{ type: "item", id: "angle:2", label: "두 선 사이" }, { type: "item", id: "angle:3", label: "세 점" }] }] }, { type: "menu", id: "align-end", label: "오른쪽 정렬 메뉴", menuAlign: "end", menuWidth: 220, entries: [{ type: "item", id: "copy", label: "좌표 복사", icon: }, { type: "item", id: "find", label: "객체 찾기", icon: }] }, ] }, ] return (
{ const entry = findEntry(entries, command.id); if (entry) apply(entry); record(`${command.itemId}/${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`) }} />
{showVerificationControls() && record(line)} /> }
) }, } function findEntry(entries: SWViewerMenuEntry[], id: string): SWViewerMenuEntry | undefined { for (const entry of entries) { if (entry.id === id) return entry if (entry.type === "submenu") { const found = findEntry(entry.entries, id); if (found) return found } } return undefined } function ContextMenuDemo({ onSelect }: { onSelect: (line: string) => void }) { const [point, setPoint] = useState<{ x: number; y: number } | null>(null) return (
{ event.preventDefault(); setPoint({ x: event.clientX, y: event.clientY }) }} data-context-area=""> 도면 영역 (오른쪽 클릭) { if (!open) setPoint(null) }} anchorPoint={point ?? undefined} label="도면 메뉴" initialFocus="menu" entries={[ { type: "item", id: "fit", label: "전체 보기", shortcut: "F" }, { type: "item", id: "zoom-selection", label: "선택 확대" }, { type: "separator", id: "s" }, { type: "submenu", id: "layer", label: "레이어", entries: LAYERS.slice(0, 4).map(layer => ({ type: "item" as const, id: `hide:${layer.name}`, label: `${layer.name} 숨기기` })) }, { type: "item", id: "props", label: "속성 보기" }, ]} onEntrySelect={entry => onSelect(`context/${entry.id}`)} />
) } export const SettingsPanel: Story = { name: "Settings Popup", // 2026-10-21: merged into the Settings Popup Story (content-variants); hidden, ID kept for the existing checks. tags: ["verification"], render: () => { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["options"] }) return ( }> ) }, } export const ContentVariants: Story = { // 2026-10-21: the one settings-popup Story (former 설정창 and 같은 프레임, 다른 내부 콘텐츠 pages); ID kept. // 2026-10-22 (user request): the 축척 지정 demo popup was removed. name: "설정창", render: () => { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties", "options"] }) return ( {/* 2026-10-22: 620px fits the tallest popup (옵션, 520px) now that 축척 지정 is gone. */} }> ) }, } export const VariableSize: Story = { name: "Variable Size Popups", // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks. tags: ["verification"], render: () => { const sizes: { id: string; title: string; width: number; height: number | "auto"; left: number; note: string }[] = [ { id: "s", title: "250 × auto (최소 폭)", width: 250, height: "auto", left: 16, note: "내용 높이" }, { id: "m", title: "320 × 420 (기본)", width: 320, height: 420, left: 282, note: "본문 스크롤" }, { id: "l", title: "480 × 300 (가로형)", width: 480, height: 300, left: 618, note: "resize: both" }, ] const [open, setOpen] = useState>({ s: true, m: true, l: true }) const lines = Array.from({ length: 24 }, (_, index) => `행 ${index + 1}`) return ( }> {sizes.map(size => ( setOpen({ ...open, [size.id]: false })} width={size.width} height={size.height}> {size.note} {(size.height === "auto" ? lines.slice(0, 3) : lines).map(line =>
{line}
)}
))}
) }, } export const Logo: Story = { name: "로고", render: () => { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) const rows: { title: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [ { title: "로고 없음 (기본)", props: {} }, { title: "logoSrc (이미지 URL)", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } }, { title: "logo (요소)", props: { logo: , logoAlt: "예시 로고" } }, ] return (
{rows.map(row => ( {/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */}
))}
) }, } export const Responsive: Story = { name: "화면 너비", render: () => { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) return (
{[1920, 1440, 1280, 950, 860, 760, 520].map(width => ( // 2026-10-22 (user request): the 1920 row no longer reaches out of the content area (no sw-story-responsive-wide); // like the other rows it is a maximum inside the Story column. 2026-10-23: it is hidden on the same narrow pages // as the SW Header / Footer 1920 rows (sw-story-responsive-hidden, sw-stories.css), e.g. a 1920px screen.
{/* The row width is a maximum (2026-10-22): on a narrower screen the header shrinks with it instead of scrolling. */} {/* Same structure as the Logo page (no scroller / bottom padding), so the spacing matches it. */}
))}
) }, } function ModeSwitchDemo() { const [mode, setMode] = useState<"normal" | "viewer">("normal") const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) const headerRef = useRef(null) return ( <>
{(["normal", "viewer"] as const).map(value => ( ))}
setMode("viewer")} /> : setMode("normal")} />}> {mode === "viewer" && <>} ) } // 2026-10-22 (user request): removed from the sidebar; ID and direct URL kept for the viewer browser tests. export const CompareWithSWHeader: Story = { name: "SWHeader vs Viewer Header", tags: ["verification"], render: () => (
), } function ViewerOnly() { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) // 2026-10-23: the same placeholder logo as the SWHeader row, so the two logo slots compare at the same width. return }