- A: SWViewerIcon draws the SWHeader black outline as SVG stroke on a copy behind the icon (no CSS filter) - B: SWViewerPanel renders the common SWPopup frame (same DOM / CSS) and adds drag, corner resize, collapse, bounds and modal; SWPopup gains optional titleActions / footer; title art spans any width - C: shared Story canvas grid (--sw-story-canvas) behind the SW Header work area and dropdown room - D: SWViewerButton tone="green" for 전체 켜기 / 전체 끄기: default kept, hover = primary (확인) hover token, pressed = primary face - AI guide, verification scripts (76 checks, element regression) and evidence updated
401 lines
25 KiB
TypeScript
401 lines
25 KiB
TypeScript
import { useRef, useState, type ReactNode } from "react"
|
||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||
import {
|
||
SWHeader, SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHint, SWViewerInput, SWViewerMenu, SWViewerPanel, SWViewerSection, SWViewerSwitch,
|
||
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 { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, 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 } },
|
||
} satisfies Meta
|
||
export default meta
|
||
type Story = StoryObj<typeof meta>
|
||
|
||
function Log({ lines }: { lines: string[] }) {
|
||
return <p className="sw-story-log" aria-live="polite" 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; height?: number; header: ReactNode; children?: ReactNode }) {
|
||
return (
|
||
<div className="sw-story-scroller">
|
||
{/* Fills the Story column up to `width`, so panels placed at the right edge stay visible. */}
|
||
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width, 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>
|
||
)
|
||
}
|
||
|
||
function ViewerScreen({ width = 1440, 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 documentTitle={demo.documentName} 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>
|
||
<StoryPageHeader kicker={KICKER} title="SW Viewer — SWViewerHeader"
|
||
description="참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다." />
|
||
<StorySection title="기본형 (1440px, 도면 열림)" description="샘플▾ Dropdown, Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.">
|
||
<ViewerScreen />
|
||
</StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
export const MenuGroups: Story = {
|
||
name: "Menu Groups",
|
||
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 — 메뉴 그룹" description="그룹은 groups 배열의 순서대로 놓입니다. surface="field"는 사용자 확정 속성 입력 면(어두운 inset), placement="end"는 오른쪽 끝 영역입니다. hidden으로 그룹·항목을 제거합니다." />
|
||
<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 documentTitle={demo.documentName} 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",
|
||
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: [] },
|
||
],
|
||
},
|
||
{
|
||
id: "field", label: "field 면", 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 }] },
|
||
{ 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"
|
||
description="Hover: #266651 면, Active·열림·선택 탭: 글자와 아이콘 #FFC600(선택 박스 없음), Disabled: 60% 불투명, Focus: 2px #FFC600 외곽선(키보드). Hover와 Focus는 마우스·Tab으로 확인합니다." />
|
||
<Log lines={log} />
|
||
<div className="sw-story-scroller"><div style={{ width: 1440 }}>
|
||
<SWViewerHeader 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",
|
||
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"
|
||
description="type="menu" 항목의 entries가 Dropdown이 됩니다. 일반 항목·체크·라디오·라벨·구분선·다단계 서브메뉴(submenu)를 지원합니다. 키보드: Enter/↓ 열기, ↑↓ 이동, → 서브메뉴, ← 닫기, Esc 닫고 버튼으로 복귀, 바깥 클릭 닫기." />
|
||
<Log lines={log} />
|
||
<div className="sw-story-scroller sw-viewer-story-room"><div style={{ width: 1440 }}>
|
||
<SWViewerHeader 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>
|
||
<StorySection title="SWViewerMenu 단독 (Context Menu)" description="anchorPoint로 지정 위치에 엽니다. 아래 영역을 마우스 오른쪽 버튼으로 클릭하세요.">
|
||
<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",
|
||
render: () => {
|
||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["options"] })
|
||
return (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 설정창 (SWViewerPanel)"
|
||
description="설정 토글로 여는 옵션 팝업입니다. 제목 막대를 끌어 이동, 오른쪽 아래 모서리로 크기 조절, 제목 더블클릭·접기 버튼으로 접기, 닫기·Esc(팝업 안 Focus)로 닫습니다. 하단 버튼 영역(footer)을 포함한 예시입니다." />
|
||
<Log lines={demo.log} />
|
||
<ViewerFrame header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||
<OptionsPanel demo={demo} strategy="absolute" footer />
|
||
</ViewerFrame>
|
||
</StoryPage>
|
||
)
|
||
},
|
||
}
|
||
|
||
export const ContentVariants: Story = {
|
||
name: "Popup Content Variants",
|
||
render: () => {
|
||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties"] })
|
||
const [inputOpen, setInputOpen] = useState(true)
|
||
const [scale, setScale] = useState("1:500")
|
||
const [snap, setSnap] = useState(true)
|
||
return (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 같은 프레임, 다른 내부 콘텐츠"
|
||
description="SWViewerPanel의 외형(제목 막대·닫기·테두리·스크롤 본문)은 같고 children / footer만 다릅니다: 검색 + 목록(레이어), 읽기 전용 정의 목록(속성), 입력 폼 + 하단 버튼(입력 팝업)." />
|
||
<Log lines={demo.log} />
|
||
<ViewerFrame height={640} header={<SWViewerHeader documentTitle={demo.documentName} 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 }} />
|
||
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
|
||
footer={<><SWViewerButton onClick={() => setInputOpen(false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record(`scale=${scale}, snap=${snap}`); setInputOpen(false) }}>적용</SWViewerButton></>}>
|
||
<SWViewerSection title="입력">
|
||
<SWViewerField label="축척">{id => <SWViewerInput id={id} value={scale} onChange={event => setScale(event.currentTarget.value)} style={{ width: 120 }} />}</SWViewerField>
|
||
<SWViewerField label="스냅 사용">{id => <SWViewerSwitch id={id} checked={snap} onCheckedChange={setSnap} />}</SWViewerField>
|
||
<SWViewerField label="단위">{id => <select id={id} className="sw-viewer-story-select" defaultValue="mm"><option value="mm">mm</option><option value="m">m</option></select>}</SWViewerField>
|
||
</SWViewerSection>
|
||
</SWViewerPanel>
|
||
{!inputOpen && <button type="button" className="sw-viewer-story-reopen" onClick={() => setInputOpen(true)}>축척 지정 다시 열기</button>}
|
||
</ViewerFrame>
|
||
</StoryPage>
|
||
)
|
||
},
|
||
}
|
||
|
||
export const VariableSize: Story = {
|
||
name: "Variable Size Popups",
|
||
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 — 가변 크기 팝업"
|
||
description="width / height("auto" 가능) / minWidth / minHeight로 메뉴별 크기를 정합니다. 최대 90vw × 90vh, 오른쪽 아래 모서리로 크기 조절, 화면(또는 strategy="absolute"의 부모) 경계 안에서만 이동합니다." />
|
||
<ViewerFrame height={520} header={<SWViewerHeader 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 Responsive: Story = {
|
||
name: "Responsive Widths",
|
||
render: () => {
|
||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||
return (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비"
|
||
description="참고 뷰어와 같은 기준입니다. Header 폭 1180px 미만: 샘플·도면 공간·2.5D·어두운 배경이 더보기(⋯)로 이동, 900px 미만: 패널 토글도 이동, 560px 미만: 파일 열기·전체 보기는 아이콘만, 상태 글자 숨김. 가로 스크롤은 만들지 않습니다." />
|
||
<Log lines={demo.log} />
|
||
<div className="sw-story-stack">
|
||
{[1440, 1100, 860, 520].map(width => (
|
||
<StorySection key={width} title={`${width}px`}>
|
||
<div className="sw-story-scroller"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
|
||
</StorySection>
|
||
))}
|
||
</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} 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>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export const CompareWithSWHeader: Story = {
|
||
name: "SWHeader vs Viewer Header",
|
||
render: () => (
|
||
<StoryPage>
|
||
<StoryPageHeader kicker={KICKER} title="SW Viewer — 일반 SW Header와 비교"
|
||
description="일반 SW 모드는 기존 SWHeader(49px, Figma 26:8944)를 그대로 쓰고, 뷰어 모드만 SWViewerHeader(52px, 참고 뷰어)를 씁니다. 두 Header는 파일명·객체 수정·속성 면 토큰을 공유합니다. 아래 전환 예시는 화면 상태(파일명·패널 상태)를 유지한 채 Header만 바꿉니다. 예시에서는 SWHeader 로고(임시)를 누르면 뷰어 모드, 뷰어 로고(임시)를 누르면 일반 모드로 돌아갑니다." />
|
||
<StorySection title="나란히 비교 (1440px)">
|
||
<div className="sw-story-stack">
|
||
<div className="sw-story-scroller"><div style={{ width: 1440 }}><SWHeader documentTitle="C0060202-001-평면및종단면도" /></div></div>
|
||
<div className="sw-story-scroller"><div style={{ width: 1440 }}><ViewerOnly /></div></div>
|
||
</div>
|
||
</StorySection>
|
||
<StorySection title="일반 ↔ 뷰어 모드 전환" description="Header 교체만 보여 주는 UI 예시입니다. 실제 모드 진입·종료와 데이터 처리는 SW 프로그램 담당입니다.">
|
||
<ModeSwitchDemo />
|
||
</StorySection>
|
||
</StoryPage>
|
||
),
|
||
}
|
||
|
||
export const Logo: Story = {
|
||
name: "Logo",
|
||
render: () => {
|
||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||
const rows: { title: string; description: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
|
||
{ title: "로고 없음 (기본)", description: "logo / logoSrc를 넘기지 않으면 아무것도 그리지 않습니다(HM 문자·기본 아이콘 없음). 로고 면은 사선 끝(24px)만 남아 넓은 빈 공간이 생기지 않습니다.", props: {} },
|
||
{ title: "logoSrc (이미지 URL)", description: "SWHeader와 같은 Prop. 로고 면 72px(로고가 더 넓으면 그만큼), 높이 최대 24px.", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } },
|
||
{ title: "logo (요소)", description: "logo가 logoSrc보다 우선합니다.", 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 kicker={KICKER} title="SW Viewer — 로고" description="로고는 각 프로그램이 정합니다. 제공된 경우에만 표시하고, Header 높이(52px)와 메뉴 정렬은 그대로입니다. SWHeader의 로고 동작은 바뀌지 않습니다." />
|
||
<div className="sw-story-stack">
|
||
{rows.map(row => (
|
||
<StorySection key={row.title} title={row.title} description={row.description}>
|
||
<div className="sw-story-scroller"><div style={{ width: 1440 }} data-logo-case={row.title}>
|
||
<SWViewerHeader {...row.props} documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||
</div></div>
|
||
</StorySection>
|
||
))}
|
||
</div>
|
||
</StoryPage>
|
||
)
|
||
},
|
||
}
|
||
|
||
function ViewerOnly() {
|
||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||
return <SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
|
||
}
|