feat(sw): viewer mode header, dropdown and popup panel

- SWViewerHeader: data-driven viewer menu (button / toggle / menu / tabs / switch / custom), More by
  collapseBelow, trailing slot, onCommand; SWHeader logo props with no default logo
- SWViewerMenu: dropdown, check / radio, multi-level submenu, context menu at a point
- SWViewerPanel: viewer popup frame with free children / footer, drag, corner resize, collapse, modal
- Panel controls: SWViewerSection / Field / Hint / Switch / Slider / Input / Button / Badge / Status
- SWHeader black text outline tokens on viewer text, matching icon outline (drop-shadow)
- Storybook SW/Viewer stories; remove SW/Tool story (SW/Popup kept)
- AI guide ai/VIEWER.md, example H, verification scripts and evidence
This commit is contained in:
shkim4 committed 2026-10-19 16:09:26 +09:00
1 parent e8409eff44
commit 7f42923ccf
50 files changed
+3838 -92

No files matched your search

-91
View File
@@ -1,91 +0,0 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { SWHeader, SWHeaderHome, SWTheme, SWToolButton, SWToolGroup } from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./sw-stories.css"
import "./sw-integrated-stories.css"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
// SWToolButton / SWToolGroup (2026-10-19): the existing SW toggle, pill and file-name looks as public buttons
// for viewer tool bars (Model / Layout, Fit, 2.5D, ...). Public API only, like a consuming project.
const KICKER = "HM DESIGN SYSTEM / SW"
// Sample host icon (the program supplies its own): 24 x 23 box like the print icon, default #B6D0C9 / hover #FFC600.
const FitIcon = ({ color }: { color: string }) => (
<svg viewBox="0 0 24 23" width="24" height="23" aria-hidden="true">
<path d="M2 8V2h6M16 2h6v6M22 15v6h-6M8 21H2v-6" fill="none" stroke={color} strokeWidth="2" />
</svg>
)
const meta = {
title: "SW/Tool",
parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
function Bar({ children }: { children: ReactNode }) {
return <div className="sw-story-scroller"><div className="sw-story-frame" style={{ width: 1440 }}>{children}</div></div>
}
function ToolPage() {
const [view, setView] = useState<"model" | "layout">("model")
const [depth, setDepth] = useState(false)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const viewGroup = (
<SWToolGroup label="보기" selection="single" data-view-group="">
<SWToolButton pressed={view === "model"} onClick={() => { setView("model"); record("view(model)") }}>Model</SWToolButton>
<SWToolButton pressed={view === "layout"} onClick={() => { setView("layout"); record("view(layout)") }}>Layout</SWToolButton>
</SWToolGroup>
)
const displayGroup = (
<SWToolGroup label="표시" data-display-group="">
<SWToolButton pressed={depth} onClick={() => { setDepth(!depth); record(`2.5D(${!depth})`) }}>2.5D</SWToolButton>
</SWToolGroup>
)
const fit = (
<SWToolGroup appearance="pill" aria-label="화면" data-fit-group="">
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />} onClick={() => record("fit")}>화면 맞춤</SWToolButton>
</SWToolGroup>
)
return (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW Tool — SWToolButton / SWToolGroup"
description="새 디자인 값 없이 기존 SW 버튼 3종을 공개 버튼으로 씁니다. text = 속성Bar·속성창 글자 토글, pill = 인쇄·K 버튼, document = 파일명 면. SWToolGroup은 상세보기 묶음(text) 또는 인쇄·K pill(pill)이며 selection=&quot;single&quot;이면 Model / Layout 같은 단일 선택(radiogroup, 화살표 키)입니다." />
<p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {log.length ? log.join(" → ") : "없음"}</p>
<StorySection title="SWHeader tools 슬롯 안" description="객체 수정 영역의 메뉴 뒤에 도구 막대를 둡니다.">
<Bar><SWHeader documentTitle="C0020101-006-평면및종단면도(06)" logoSrc={placeholderLogo} logoAlt="임시 로고"
hiddenControls={["lineStyle", "textStyle", "dimensionStyle", "details", "propertyBar", "propertyPanel"]}
tools={<>{viewGroup}{displayGroup}{fit}</>} /></Bar>
</StorySection>
<StorySection title="조합형 막대 (SWTheme)" description="Header 없이 같은 부품을 SWTheme 안에 배치합니다. 배치 CSS만 소비 프로젝트 소유입니다.">
<Bar>
<SWTheme className="sw-story-composed-bar" data-tool-bar="">
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" />
<SWToolButton appearance="document" onClick={() => record("open")}>Open</SWToolButton>
<div className="sw-story-composed-surface">{viewGroup}{displayGroup}{fit}</div>
</SWTheme>
</Bar>
</StorySection>
<StorySection title="상태" description="text: Hover·Selected 글자 #FFC600, Disabled 40%. pill: Hover 노란 테두리, hoverIcon 교체. document: 글자만 Hover #FFC600.">
<Bar>
<SWTheme className="sw-story-composed-bar" data-tool-states="">
<SWHeaderHome logoSrc={placeholderLogo} logoAlt="임시 로고" interactive={false} />
<SWToolButton appearance="document" disabled>Disabled</SWToolButton>
<div className="sw-story-composed-surface">
<SWToolGroup label="text"><SWToolButton>Default</SWToolButton><SWToolButton pressed>Pressed</SWToolButton><SWToolButton disabled>Disabled</SWToolButton></SWToolGroup>
<SWToolGroup appearance="pill" aria-label="pill">
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />}>Fit</SWToolButton>
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} disabled>Fit disabled</SWToolButton>
</SWToolGroup>
</div>
</SWTheme>
</Bar>
</StorySection>
</StoryPage>
)
}
export const ToolButtons: Story = { name: "Tool Buttons", render: () => <ToolPage /> }
+195
View File
@@ -0,0 +1,195 @@
// Story fixture for SW/Viewer: the reference DWG/DXF viewer menu rebuilt from the public @hm-design/sw API only.
// UI state lives here (like a consuming program); no DWG/DXF engine — commands are logged, not executed.
import { useMemo, useState } from "react"
import {
SWViewerBadge, SWViewerButton, SWViewerHeaderButton, SWViewerStatus, SWViewerField, SWViewerHint, SWViewerInput, SWViewerPanel, SWViewerSection, SWViewerSlider, SWViewerSwitch,
type SWViewerCommand, type SWViewerHeaderGroup, type SWViewerPanelProps,
} from "@hm-design/sw"
import { Eye, EyeOff, FolderOpen, Info, Layers, List, MousePointerClick, RotateCcw, Scan, SlidersHorizontal, ZoomIn } from "lucide-react"
export const SAMPLES = [
{ id: "sample-basic", label: "기본 도면 · DWG", name: "BasicSample.dwg" },
{ id: "sample-civil", label: "평면 및 종단면도 · DWG", name: "C0060202-001-평면및종단면도.dwg" },
{ id: "sample-dxf", label: "기본 도형 · DXF", name: "simple.dxf" },
] as const
export const SPACES = [
{ value: "model", label: "Model", count: 17377 },
{ value: "layout1", label: "Layout", count: 237 },
]
export const LAYERS = [
{ name: "0", color: "#ffffff", count: 1 },
{ name: "CX-BORD-LIN1", color: "#00ffff", count: 30 },
{ name: "CX-BORD-LIN2", color: "#00ff00", count: 1 },
{ name: "CX-BORD-TEX3", color: "#ff00ff", count: 17 },
{ name: "CX-BORD-TEX2", color: "#000000", count: 4 },
{ name: "CX-MISC", color: "#ffff00", count: 0 },
{ name: "CR-LEAT", color: "#00ff00", count: 4 },
{ name: "CR-GRND", color: "#00ff00", count: 6 },
{ name: "CR-TEXT", color: "#ff0000", count: 52 },
]
export type ViewerPanelId = "layers" | "options" | "properties"
export interface ViewerOptions { lineweightOn: boolean; lineweightScale: number; grid: boolean; zoomSpeed: number; zScale: number }
export const DEFAULT_OPTIONS: ViewerOptions = { lineweightOn: false, lineweightScale: 11, grid: false, zoomSpeed: 1, zScale: 10 }
/** All UI state of the demo viewer screen + the header menu data built from it. */
export function useViewerDemo({ initialDocument = null as string | null, initialPanels = [] as ViewerPanelId[] } = {}) {
const [documentName, setDocumentName] = useState<string | null>(initialDocument)
const [space, setSpace] = useState("model")
const [is25D, set25D] = useState(false)
const [dark, setDark] = useState(true)
const [panels, setPanels] = useState<Record<ViewerPanelId, boolean>>(() => ({ layers: initialPanels.includes("layers"), options: initialPanels.includes("options"), properties: initialPanels.includes("properties") }))
const [options, setOptions] = useState<ViewerOptions>(DEFAULT_OPTIONS)
const [hidden, setHidden] = useState<Set<string>>(new Set())
const [aboutOpen, setAboutOpen] = useState(false)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
const toggle = (id: ViewerPanelId, next = !panels[id]) => setPanels(current => ({ ...current, [id]: next }))
const loaded = documentName !== null
const groups = useMemo<SWViewerHeaderGroup[]>(() => [
{
id: "file", label: "파일", items: [
{ type: "button", id: "open", label: "파일 열기", icon: <FolderOpen />, title: "파일 열기", iconOnlyBelow: 560, onSelect: () => record("파일 선택창 (미연결)") },
{
type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180,
entries: SAMPLES.map(sample => ({ type: "item" as const, id: sample.id, label: sample.label, onSelect: () => setDocumentName(sample.name) })),
},
],
},
{
id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [
{ type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace },
],
},
{
id: "view", label: "화면 보기", surface: "field", items: [
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, disabled: !loaded },
{ type: "switch", id: "2.5d", label: "2.5D", checked: is25D, collapseBelow: 1180, onCheckedChange: set25D },
{ type: "switch", id: "dark", label: "어두운 배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark },
],
},
{
id: "panels", label: "패널", placement: "end", items: [
{ type: "toggle", id: "layers", label: "레이어", icon: <Layers />, title: "레이어 패널 열기 / 닫기", pressed: panels.layers, collapseBelow: 900, onPressedChange: next => toggle("layers", next) },
{ type: "toggle", id: "options", label: "설정", icon: <SlidersHorizontal />, title: "옵션 패널 열기 / 닫기", pressed: panels.options, collapseBelow: 900, onPressedChange: next => toggle("options", next) },
{ type: "toggle", id: "properties", label: "속성", icon: <List />, title: "속성 패널 열기 / 닫기", pressed: panels.properties, collapseBelow: 900, onPressedChange: next => toggle("properties", next) },
],
},
], [loaded, space, is25D, dark, panels])
/** Trailing slot: load state + the info button that opens the modal about popup (reference LicenseNotice). */
const trailing = (
<>
<SWViewerStatus state={loaded ? "ready" : "idle"} detail={loaded ? `${documentName} 열림` : "deselected"} />
<SWViewerHeaderButton iconOnly icon={<Info />} label="정보 및 라이선스" title="정보 및 라이선스" onClick={() => { setAboutOpen(true); record("about") }} />
</>
)
const onCommand = (command: SWViewerCommand) => record(`${command.itemId}${command.id !== command.itemId ? `/${command.id}` : ""}${command.value !== undefined ? `=${String(command.value)}` : ""}`)
return {
documentName, setDocumentName, space, is25D, set25D, dark, setDark, panels, toggle, options, setOptions, hidden, setHidden,
log, record, groups, trailing, onCommand, loaded, aboutOpen, setAboutOpen,
}
}
export type ViewerDemo = ReturnType<typeof useViewerDemo>
type PanelPlacement = Pick<SWViewerPanelProps, "strategy" | "defaultPosition" | "inset">
export function LayerPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelPlacement) {
const [query, setQuery] = useState("")
const filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
const set = (names: string[]) => demo.setHidden(new Set(names))
return (
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} icon={<Layers />} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<div className="sw-viewer-story-toolbar">
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
<SWViewerButton onClick={() => set([])}>전체 켜기</SWViewerButton>
<SWViewerButton onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
</div>
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
<ul className="sw-viewer-story-list">
{filtered.map(layer => {
const visible = !demo.hidden.has(layer.name)
return (
<li key={layer.name} data-visible={visible}>
<span className="sw-viewer-story-swatch" style={{ background: layer.color }} aria-hidden="true" />
<span className="sw-viewer-story-name" title={layer.name}>{layer.name}</span>
<SWViewerBadge>{layer.count}</SWViewerBadge>
<button type="button" className="sw-viewer-story-eye" aria-label={`${layer.name} ${visible ? "숨기기" : "표시하기"}`} title={visible ? "숨기기" : "표시하기"}
onClick={() => {
const next = new Set(demo.hidden)
if (visible) next.add(layer.name); else next.delete(layer.name)
demo.setHidden(next)
demo.record(`layer:${layer.name}=${!visible}`)
}}>{visible ? <Eye size={16} /> : <EyeOff size={16} />}</button>
</li>
)
})}
</ul>
)}
</SWViewerSection>
</SWViewerPanel>
)
}
function SliderRow({ label, value, min, max, step, disabled, onChange }: { label: string; value: number; min: number; max: number; step: number; disabled?: boolean; onChange: (value: number) => void }) {
return (
<SWViewerField label={label} layout="stacked" disabled={disabled}
below={<SWViewerSlider aria-label={label} value={value} min={min} max={max} step={step} onValueChange={onChange} />}>
{id => <SWViewerInput id={id} type="number" min={min} max={max} step={step} value={value} onChange={event => { const next = Number(event.currentTarget.value); if (Number.isFinite(next)) onChange(next) }} />}
</SWViewerField>
)
}
export function OptionsPanel({ demo, footer, ...placement }: { demo: ViewerDemo; footer?: boolean } & PanelPlacement) {
const { options, setOptions } = demo
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
return (
<SWViewerPanel title="옵션" icon={<SlidersHorizontal />} open={demo.panels.options} onClose={() => demo.toggle("options", false)} width={340} height={520} {...placement}
headerActions={<SWViewerButton variant="ghost" aria-label="기본값으로" title="기본값으로" onClick={() => { setOptions(DEFAULT_OPTIONS); demo.record("options:reset") }}><RotateCcw size={16} /></SWViewerButton>}
footer={footer ? <><SWViewerButton onClick={() => demo.toggle("options", false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record("options:apply"); demo.toggle("options", false) }}>적용</SWViewerButton></> : undefined}>
<SWViewerSection title="선가중치">
<SWViewerField label="표시 사용">{id => <SWViewerSwitch id={id} checked={options.lineweightOn} onCheckedChange={lineweightOn => set({ lineweightOn })} />}</SWViewerField>
<SliderRow label="표시 배율 (px/mm)" value={options.lineweightScale} min={2} max={60} step={0.5} onChange={lineweightScale => set({ lineweightScale })} />
<SWViewerHint>이 도면에는 굵은 선이 없습니다 (0.50mm 였다면 {(0.5 * options.lineweightScale).toFixed(1)}px)</SWViewerHint>
<SWViewerHint>AutoCAD의 "선가중치 설정 → 표시 배율"과 동일합니다.</SWViewerHint>
</SWViewerSection>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={options.grid} onCheckedChange={grid => set({ grid })} />}</SWViewerField>
<SliderRow label="줌 속도" value={options.zoomSpeed} min={0.2} max={3} step={0.1} onChange={zoomSpeed => set({ zoomSpeed })} />
</SWViewerSection>
<SWViewerSection title="2.5D">
<SWViewerField label="2.5D 보기">{id => <SWViewerSwitch id={id} checked={demo.is25D} onCheckedChange={demo.set25D} />}</SWViewerField>
<SliderRow label="Z 높이 배율" value={options.zScale} min={1} max={50} step={0.5} disabled={!demo.is25D} onChange={zScale => set({ zScale })} />
<SWViewerHint>좌클릭 드래그로 회전, 중간/우클릭 드래그로 이동합니다.</SWViewerHint>
</SWViewerSection>
</SWViewerPanel>
)
}
const ENTITY_ROWS: [string, string][] = [["유형", "LINE"], ["레이어", "CX-BORD-LIN1"], ["색상", "ByLayer"], ["선종류", "Continuous"]]
const GEOMETRY_ROWS: [string, string][] = [["시작 X", "18432.512"], ["시작 Y", "2210.004"], ["끝 X", "18992.040"], ["끝 Y", "2210.004"], ["길이", "559.528"]]
export function PropertiesPanel({ demo, selected = true, ...placement }: { demo: ViewerDemo; selected?: boolean } & PanelPlacement) {
const rows = (items: [string, string][]) => (
<dl className="sw-viewer-story-props">{items.map(([key, value]) => <div key={key}><dt>{key}</dt><dd title={value}>{value}</dd></div>)}</dl>
)
return (
<SWViewerPanel title="속성" icon={<Info />} open={demo.panels.properties} onClose={() => demo.toggle("properties", false)} {...placement}
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
{selected ? <>
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
<SWViewerSection surface="plain" title="기하 속성 (Geometry)">{rows(GEOMETRY_ROWS)}</SWViewerSection>
</> : (
<div className="sw-viewer-story-empty"><MousePointerClick size={24} aria-hidden="true" />도면에서 객체(엔티티)를 클릭하면<br />상세 속성이 표시됩니다</div>
)}
</SWViewerPanel>
)
}
+33
View File
@@ -0,0 +1,33 @@
/* Story-owned layout for SW/Viewer. Like a consuming program it arranges public components and its own list
content; it never styles hm-sw-* classes. Panel content colors read the public --hm-sw-viewer-* tokens. */
.sw-viewer-story-frame { position: relative; display: flex; flex-direction: column; overflow: hidden; background: #0a0a0b; }
.sw-viewer-story-canvas {
position: relative;
flex: 1;
min-height: 0;
background:
linear-gradient(rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px,
linear-gradient(90deg, rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px,
#0a0a0b;
}
.sw-viewer-story-room { padding-bottom: 320px; }
.sw-viewer-story-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 12px 4px; }
.sw-viewer-story-toolbar > input { flex: 1 0 100%; }
.sw-viewer-story-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
.sw-viewer-story-list li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 3px; }
.sw-viewer-story-list li:hover { background: rgba(154, 146, 132, 0.25); }
.sw-viewer-story-list li[data-visible="false"] { opacity: 0.45; }
.sw-viewer-story-swatch { flex: 0 0 10px; width: 10px; height: 10px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 9999px; }
.sw-viewer-story-name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: ui-monospace, monospace; font-size: 12px; }
.sw-viewer-story-eye { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 4px; color: var(--hm-sw-viewer-panel-heading); }
.sw-viewer-story-eye:hover { background: rgba(154, 146, 132, 0.3); }
.sw-viewer-story-props { margin: 0; }
.sw-viewer-story-props > div { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--hm-sw-viewer-panel-inner-border); font-size: 12px; }
.sw-viewer-story-props dt { color: var(--hm-sw-viewer-panel-label); }
.sw-viewer-story-props dd { margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: ui-monospace, monospace; user-select: all; }
.sw-viewer-story-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 0; text-align: center; font-size: 12px; color: var(--hm-sw-viewer-panel-label); }
.sw-viewer-story-steps { margin: 0; padding-left: 20px; font-size: 14px; }
.sw-viewer-story-select { height: 28px; padding: 0 8px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 4px; background: var(--hm-sw-viewer-panel-field-background); font: inherit; font-size: 12px; }
.sw-viewer-story-reopen { position: absolute; left: 712px; top: 16px; padding: 6px 10px; border-radius: 4px; background: #f5f0e5; font-size: 12px; }
.sw-viewer-story-context { display: flex; align-items: center; justify-content: center; height: 160px; border: 1px dashed #9a9284; border-radius: 4px; background: #f5f0e5; color: #3e3523; font-size: 13px; }
.sw-viewer-story-mode { display: flex; gap: 16px; margin-bottom: 8px; font-size: 13px; }
+400
View File
@@ -0,0 +1,400 @@
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, Settings2 } 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=&quot;field&quot;는 사용자 확정 속성 입력 면(어두운 inset), placement=&quot;end&quot;는 오른쪽 끝 영역입니다. 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=&quot;menu&quot; 항목의 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="축척 지정" icon={<Settings2 />} 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(&quot;auto&quot; 가능) / minWidth / minHeight로 메뉴별 크기를 정합니다. 최대 90vw × 90vh, 오른쪽 아래 모서리로 크기 조절, 화면(또는 strategy=&quot;absolute&quot;의 부모) 경계 안에서만 이동합니다." />
<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} />
}