Files
HMDesign/src/sw/sw-viewer.stories.tsx
T
shkim4 9394050c72 feat(storybook): add shared SW notice and refine SW previews
- Add StoryNotice and a withSWNotice decorator so every visible SW Story
  shows the common notice above its title; non-SW Stories are unaffected.
- Align the SW Header overview with the common SW Story page layout, let the
  preview Stage fill the available width and keep inner scroll on narrow screens.
- Rename SW/Footer to SW/하단 명령어바 (sidebar title only, Story IDs fixed via
  id: "sw-footer"), drop the top SWHeader from its preview and lower the
  workspace height bounds by the removed 49px.
2026-11-09 19:23:17 +09:00

418 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof meta>
// Command record for the browser tests only (2026-10-21 Storybook simplification: not shown on the page).
function Log({ lines }: { lines: string[] }) {
return <p hidden data-callback-log="">Command: {lines.length ? lines.join(" → ") : "없음"}</p>
}
/** 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 (
<div className="sw-story-scroller">
{/* Fills the Story column up to `width` (null: the whole column), so panels placed at the right edge stay visible. */}
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width ?? undefined, height }} data-viewer-frame="">
{header}
<div className="sw-viewer-story-canvas" aria-label="도면 영역 (예시)">{children}</div>
</div>
</div>
)
}
function AboutPanel({ demo }: { demo: ViewerDemo }) {
return (
<SWViewerPanel modal strategy="absolute" title="모듈 구성 · 라이선스 고지" open={demo.aboutOpen} onClose={() => demo.setAboutOpen(false)} width={420} height="auto" resizable={false} collapsible={false}
footer={<SWViewerButton variant="primary" onClick={() => demo.setAboutOpen(false)}>확인</SWViewerButton>}>
<SWViewerSection title="처리 파이프라인">
<ol className="sw-viewer-story-steps">
<li>파일 로드 (.dwg → acadrust WASM, .dxf → dxf-parser)</li>
<li>CAD 파싱 결과 정규화 (레이어 / 스페이스 / 엔티티)</li>
<li>Three.js 씬 구성 및 렌더링</li>
</ol>
</SWViewerSection>
<SWViewerHint>Storybook 예시: 링크와 실제 고지문은 프로그램이 제공합니다.</SWViewerHint>
</SWViewerPanel>
)
}
// 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 (
<>
<Log lines={demo.log} />
<ViewerFrame width={width} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" />
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
<AboutPanel demo={demo} />
</ViewerFrame>
</>
)
}
export const ViewerHeader: Story = {
name: "Viewer Header",
render: () => (
<StoryPage>
{/* 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). */}
<StoryPageHeader title="SW Viewer Header" />
<StorySection title="기본형">
<ViewerScreen />
</StorySection>
</StoryPage>
),
}
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 (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 메뉴 그룹" />
<Log lines={demo.log} />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={pick(row.ids)} trailing={row.ids.includes("panels") ? demo.trailing : undefined} onCommand={demo.onCommand} /></div></div>
</StorySection>
))}
</div>
</StoryPage>
)
},
}
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<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const groups: SWViewerHeaderGroup[] = [
{
id: "states", label: "상태 예시", items: [
{ type: "button", id: "default", label: "기본", icon: <FolderOpen /> },
{ type: "toggle", id: "active", label: "Active (on)", icon: <Layers />, pressed: true },
{ type: "toggle", id: "inactive", label: "Toggle (off)", icon: <Layers />, pressed: false },
{ type: "button", id: "disabled", label: "Disabled", icon: <Scan />, 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 (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — Active / Hover / Disabled" />
<Log lines={log} />
<div className="sw-story-scroller"><div style={{ width: 1440 }}>
<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle="상태 확인용.dwg" groups={groups} onCommand={command => record(`${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`)} />
</div></div>
</StoryPage>
)
},
}
const MENU_ENTRIES: SWViewerMenuEntry[] = [
{ type: "label", id: "l-file", label: "파일" },
{ type: "item", id: "open", label: "파일 열기", icon: <FolderOpen size={16} />, shortcut: "Ctrl+O" },
{
type: "submenu", id: "recent", label: "최근 도면", icon: <FileText size={16} />, 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: <Download size={16} /> },
{ type: "item", id: "print", label: "인쇄 (미연결)", icon: <Printer size={16} />, 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<string[]>([])
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: <FileText />, entries: entries.map(entry => ({ ...entry, ...(entry.type === "checkbox" || entry.type === "radio" ? { keepOpen: false } : {}) })) }] },
{ id: "tools", label: "도구", items: [
{ type: "menu", id: "measure", label: "측정", icon: <Ruler />, 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: <Copy size={16} /> }, { type: "item", id: "find", label: "객체 찾기", icon: <Search size={16} /> }] },
] },
]
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 상단 메뉴 + Dropdown" />
<Log lines={log} />
<div className="sw-story-scroller sw-viewer-story-room"><div style={{ width: 1440 }}>
<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle="C0060202-001-평면및종단면도.dwg" groups={groups}
onCommand={command => { const entry = findEntry(entries, command.id); if (entry) apply(entry); record(`${command.itemId}/${command.id}${command.value !== undefined ? `=${String(command.value)}` : ""}`) }} />
</div></div>
{showVerificationControls() && <StorySection title="SWViewerMenu 단독 (Context Menu)">
<ContextMenuDemo onSelect={line => record(line)} />
</StorySection>}
</StoryPage>
)
},
}
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 (
<div className="sw-viewer-story-context" onContextMenu={event => { event.preventDefault(); setPoint({ x: event.clientX, y: event.clientY }) }} data-context-area="">
도면 영역 (오른쪽 클릭)
<SWViewerMenu open={point !== null} onOpenChange={open => { 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}`)} />
</div>
)
}
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 (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)" />
<Log lines={demo.log} />
<ViewerFrame header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<OptionsPanel demo={demo} strategy="absolute" />
</ViewerFrame>
</StoryPage>
)
},
}
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 (
<StoryPage>
<StoryPageHeader title="SW Viewer — 설정창 (SWViewerPanel)" />
<Log lines={demo.log} />
{/* 2026-10-22: 620px fits the tallest popup (옵션, 520px) now that 축척 지정 is gone. */}
<ViewerFrame height={620} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
<OptionsPanel demo={demo} strategy="absolute" defaultPosition={{ left: 712, top: 16 }} />
</ViewerFrame>
</StoryPage>
)
},
}
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<Record<string, boolean>>({ s: true, m: true, l: true })
const lines = Array.from({ length: 24 }, (_, index) => `행 ${index + 1}`)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Viewer — 가변 크기 팝업" />
<ViewerFrame height={520} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle="크기 비교.dwg" groups={[]} />}>
{sizes.map(size => (
<SWViewerPanel key={size.id} strategy="absolute" defaultPosition={{ left: size.left, top: 16 }} title={size.title} open={open[size.id]} onClose={() => setOpen({ ...open, [size.id]: false })} width={size.width} height={size.height}>
<SWViewerHint>{size.note}</SWViewerHint>
<SWViewerSection surface="plain">{(size.height === "auto" ? lines.slice(0, 3) : lines).map(line => <div key={line}>{line}</div>)}</SWViewerSection>
</SWViewerPanel>
))}
</ViewerFrame>
</StoryPage>
)
},
}
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: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
]
return (
<StoryPage>
<StoryPageHeader title="SW Viewer — 로고" />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
{/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */}
<div style={{ width: "100%", maxWidth: 1440 }} data-logo-case={row.title}>
<SWViewerHeader {...row.props} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
</div>
</StorySection>
))}
<Log lines={demo.log} />
</div>
</StoryPage>
)
},
}
export const Responsive: Story = {
name: "화면 너비",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
return (
<StoryPage>
<StoryPageHeader title="SW Viewer — 화면 너비" />
<Log lines={demo.log} />
<div className="sw-story-stack sw-story-responsive">
{[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.
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined}><StorySection title={`${width}px`}>
{/* 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. */}
<div style={{ width: "100%", maxWidth: width }} data-width={width}><SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div>
</StorySection></div>
))}
</div>
</StoryPage>
)
},
}
function ModeSwitchDemo() {
const [mode, setMode] = useState<"normal" | "viewer">("normal")
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
const headerRef = useRef<HTMLElement>(null)
return (
<>
<div className="sw-viewer-story-mode" role="radiogroup" aria-label="모드">
{(["normal", "viewer"] as const).map(value => (
<label key={value}><input type="radio" name="sw-mode" checked={mode === value} onChange={() => setMode(value)} /> {value === "normal" ? "일반 SW 모드 (SWHeader)" : "뷰어 모드 (SWViewerHeader)"}</label>
))}
</div>
<Log lines={demo.log} />
<ViewerFrame height={480} header={mode === "normal"
? <SWHeader ref={headerRef} documentTitle={demo.documentName ?? ""} logoSrc={placeholderLogo} logoAlt="임시 로고 (뷰어 모드로)" onHomeClick={() => setMode("viewer")} />
: <SWViewerHeader ref={headerRef} documentTitle={demo.documentName} documents={demo.fileProps.documents} activeDocumentId={demo.fileProps.activeDocumentId} onDocumentSelect={demo.fileProps.onDocumentSelect} onDocumentOpen={demo.fileProps.onDocumentOpen} documentOpenLabel={demo.fileProps.documentOpenLabel} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} logoSrc={placeholderLogo} logoAlt="임시 로고 (일반 모드로)" onHomeClick={() => setMode("normal")} />}>
{mode === "viewer" && <><LayerPanel demo={demo} strategy="absolute" /><OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} /></>}
</ViewerFrame>
</>
)
}
// 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: () => (
<StoryPage>
<StoryPageHeader title="SW Viewer — 일반 SW Header와 비교" />
<StorySection title="나란히 비교 (1440px)">
<div className="sw-story-stack">
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWHeader logoSrc={placeholderLogo} logoAlt="임시 로고" documentTitle="C0060202-001-평면및종단면도" /></div></div>
<div className="sw-story-scroller"><div style={{ width: 1440 }}><ViewerOnly /></div></div>
</div>
</StorySection>
<StorySection title="일반 ↔ 뷰어 모드 전환">
<ModeSwitchDemo />
</StorySection>
</StoryPage>
),
}
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 <SWViewerHeader logoSrc={placeholderLogo} logoAlt="임시 로고" documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
}