Files
HMDesign/src/sw/sw-header-overview.tsx
T

200 lines
14 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 { useId, useLayoutEffect, useRef, useState, type CSSProperties } from "react"
import {
SWHeader, parseSWHeaderColor, type SWHeaderProps, type SWHeaderProperty, type SWHeaderPropertyGroup,
type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderColorMode, type SWHeaderLayer, type SWHeaderLineWeight,
} from "@hm-design/sw"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
type Selection = "none" | "all" | "circle" | "expanded"
interface OverviewProps {
initial: SWHeaderProps
fixtures: Required<Pick<SWHeaderProps, "documents" | "layers" | "colors" | "userColors" | "lineTypes" | "lineWeights" | "textStyles" | "dimensionStyles">>
propertyGroups: SWHeaderPropertyGroup[]
propertyFixture: (selection: Selection) => SWHeaderProperty[]
propertySelection: (selection: Selection) => SWHeaderPropertySelection
barFixture: SWHeaderProperty[]
}
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture }: OverviewProps) {
const controlId = useId()
const slotRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
const [panelLeft, setPanelLeft] = useState(4)
const [example, setExample] = useState<Selection>("circle")
const getProperties = (selection: Selection) => propertyFixture(selection).map(property => ({
...property, value: barFixture.find(item => item.id === property.id)?.value ?? property.value,
}))
const [properties, setProperties] = useState(() => getProperties("circle"))
const [documents, setDocuments] = useState(fixtures.documents)
const [documentId, setDocumentId] = useState(fixtures.documents[0]?.id)
const [layers, setLayers] = useState(fixtures.layers)
const [weights, setWeights] = useState(fixtures.lineWeights)
const [weightId, setWeightId] = useState("w-default")
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
const [textStyle, setTextStyle] = useState(fixtures.textStyles[0])
const [dimensionStyle, setDimensionStyle] = useState(fixtures.dimensionStyles[0])
const [barOpen, setBarOpen] = useState(true)
const [panelOpen, setPanelOpen] = useState(false)
const [expanded, setExpanded] = useState<string[]>([])
const [readOnly, setReadOnly] = useState(false)
const [disabled, setDisabled] = useState(false)
const [emptyShape, setEmptyShape] = useState(false)
const [callbackLog, setCallbackLog] = useState<string[]>([])
const counter = useRef(0)
const record = (line: string) => { setCallbackLog(lines => [...lines.slice(-5), line]); console.info(`[SWHeader Overview] ${line}`) }
const value = (id: string) => properties.find(property => property.id === id)?.value ?? ""
const change = (id: string, next: string, event = "onPropertyChange") => {
setProperties(items => items.map(property => property.id === id ? {
...property, value: next, mixed: false, ...(id === "color" ? { swatch: parseSWHeaderColor(next)?.hex } : {}),
} : property))
if (id === "color") setColorMode(null)
record(`${event}(${id}, ${next})`)
}
const onPropertyChange = (property: SWHeaderProperty, next: string) => change(property.id, next)
const changeExample = (next: Selection) => {
setExample(next); setProperties(getProperties(next)); setExpanded(next === "expanded" ? ["common"] : [])
record(`selection(${next})`)
}
useLayoutEffect(() => {
const slot = slotRef.current
if (!slot) return
const measure = () => {
const bounds = slot.getBoundingClientRect()
const width = Math.max(0, Math.min(bounds.width, bounds.height * 16 / 9))
setSize(previous => previous.width === width ? previous : { width, height: width * 9 / 16 })
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(slot)
return () => observer.disconnect()
}, [])
useLayoutEffect(() => {
const header = headerRef.current
if (!header) return
const measure = () => {
const bounds = header.getBoundingClientRect()
const trigger = header.querySelector<HTMLElement>("[data-property-panel-trigger]")
const anchor = trigger?.getClientRects().length ? trigger : header.querySelector<HTMLElement>(".hm-sw-header__more-trigger")
setPanelLeft(Math.max(4, Math.min((anchor?.getBoundingClientRect().left ?? bounds.left) - bounds.left, bounds.width - 318)))
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(header)
return () => observer.disconnect()
}, [])
const toggleLayer = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
record(`layer.${key}(${layer.id}, ${next})`)
}
const chooseWeight = (weight: SWHeaderLineWeight) => {
setWeightId(weight.id)
// ByLayer/ByBlock select a mode, not a numeric property value (same policy as usePropertyMenus).
if (weight.value !== undefined) change("weight", weight.value, "onLineWeightSelect")
else record(`onLineWeightSelect(${weight.id})`)
}
const changeWeight = (weight: SWHeaderLineWeight, next: string) => {
setWeights(items => items.map(item => item.id === weight.id ? { ...item, value: next } : item))
if (weightId === weight.id) change("weight", next, "onLineWeightValueChange")
else record(`onLineWeightValueChange(${weight.id}, ${next})`)
}
const changeMode = (mode: SWHeaderColorMode) => { setColorMode(mode); record(`onColorModeChange(${mode})`) }
const scale = value("scale")
const menus: SWHeaderPropertyMenus = {
layers, onLayerVisibilityChange: toggleLayer("visible"), onLayerFreezeChange: toggleLayer("frozen"), onLayerLockChange: toggleLayer("locked"),
onLayerDetail: () => record("onLayerDetail"), lineTypes: fixtures.lineTypes, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
onLineWeightSelect: chooseWeight, onLineWeightValueChange: changeWeight,
onLineScaleChange: next => change("scale", Number(next).toFixed(4), "onLineScaleChange"), onLineStyleDetail: () => record("onLineStyleDetail"),
colors: fixtures.colors, userColors: fixtures.userColors, colorMode, onColorModeChange: changeMode,
onColorPick: () => record("onColorPick"), onColorDetail: () => record("onColorDetail"),
}
const guardedProperties = properties.map(property => ({ ...property, disabled: disabled || property.disabled, readOnly: readOnly || property.readOnly }))
const barProperties = guardedProperties.flatMap(property => {
const bar = barFixture.find(item => item.id === property.id)
return bar ? [{ ...bar, value: property.value, disabled: property.disabled, readOnly: property.readOnly }]
: property.id.startsWith("preview-extra-") ? [property] : []
})
const layer = layers.find(item => item.id === value("layer"))
const line = fixtures.lineTypes.find(item => item.id === value("line"))
const color = parseSWHeaderColor(value("color"))
const weight = weights.find(item => item.id === weightId)
return <StoryPage className="sw-story-overview">
<StoryPageHeader kicker="HM DESIGN SYSTEM / SW" title="SW Header" />
<div className="sw-overview-toolbar" aria-label="Storybook 전용 미리보기 설정">
<label htmlFor={`${controlId}-selection`}>선택 상태</label>
<select id={`${controlId}-selection`} value={example} onChange={event => changeExample(event.target.value as Selection)}>
<option value="none">선택 없음</option><option value="all">전체 객체</option><option value="circle">원 선택</option><option value="expanded">전체 펼침</option>
</select>
<details><summary>시연 데이터</summary><div className="sw-overview-data">
<button onClick={() => setProperties(items => items.map(property => barFixture.some(item => item.id === property.id) ? { ...property, value: "12.5" } : property))}>외부 값 12.5</button>
<button onClick={() => {
const number = ++counter.current
setProperties(items => [...items, { id: `preview-extra-${number}`, name: `추가 속성 ${number}`, value: "10", type: "number", groupId: "shape", min: 0 }])
}}>필드 추가</button>
<button onClick={() => setProperties(items => items.filter(item => !item.id.startsWith("preview-extra-")))}>추가 필드 제거</button>
<label><input type="checkbox" checked={readOnly} onChange={event => setReadOnly(event.target.checked)} />속성 ReadOnly</label>
<label><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} />속성 Disabled</label>
<label><input type="checkbox" checked={emptyShape} onChange={event => setEmptyShape(event.target.checked)} />형상 Slot 비우기</label>
</div></details>
</div>
<div ref={slotRef} className="sw-overview-slot" data-preview-slot="">
<div className="sw-overview-stage" data-preview-stage="" role="region" tabIndex={0} aria-label="SW Header 16:9 미리보기, 작은 화면에서는 내부 스크롤"
style={{ ...size, "--sw-preview-panel-left": `${panelLeft}px`, "--sw-preview-panel-top": barOpen ? "77px" : "49px" } as CSSProperties}>
<div className="sw-overview-world">
<SWHeader ref={headerRef} {...initial}
documents={documents} activeDocumentId={documentId} documentTitle={documents.find(document => document.id === documentId)?.name ?? "열린 문서 없음"}
onDocumentClick={() => record("onDocumentClick")} onDocumentMenuOpenChange={open => record(`documentOpen(${open})`)}
onDocumentSelect={document => { setDocumentId(document.id); record(`onDocumentSelect(${document.id})`) }}
onDocumentClose={document => {
const next = documents.filter(item => item.id !== document.id); setDocuments(next)
if (documentId === document.id) setDocumentId(next[0]?.id)
record(`onDocumentClose(${document.id})`)
}}
onDocumentNew={() => {
const id = `preview-document-${++counter.current}`
setDocuments(items => [...items, { id, name: `새 문서 ${counter.current}.dwg` }]); setDocumentId(id); record("onDocumentNew")
}} onDocumentOpen={() => record("onDocumentOpen")}
layers={layers} layer={{ name: layer?.name ?? value("layer") }} activeLayerId={layer?.id}
onLayerSelect={item => change("layer", item.id, "onLayerSelect")}
onLayerVisibilityChange={menus.onLayerVisibilityChange} onLayerFreezeChange={menus.onLayerFreezeChange} onLayerLockChange={menus.onLayerLockChange}
onLayerOpen={() => record("onLayerOpen")} onLayerDetail={menus.onLayerDetail}
colors={fixtures.colors} userColors={fixtures.userColors}
color={{ value: colorMode === "byLayer" ? "ByLayer" : colorMode === "byBlock" ? "ByBlock" : value("color"), swatch: color?.hex }} colorMode={colorMode}
onColorSelect={item => change("color", item.value ?? parseSWHeaderColor(item.color)?.value ?? item.color, "onColorSelect")}
onColorInput={next => change("color", next.value, "onColorInput")} onColorModeChange={changeMode}
onColorOpen={() => record("onColorOpen")} onColorDetail={menus.onColorDetail} onColorPick={() => record("onColorPick")}
lineTypes={fixtures.lineTypes} lineWeights={weights} activeLineTypeId={line?.id} activeLineWeightId={weightId}
lineStyle={{ name: line?.name ?? value("line"), weight: weight && weight.value === undefined ? weight.name : value("weight"), scale }}
onLineTypeSelect={item => change("line", item.id, "onLineTypeSelect")} onLineWeightSelect={chooseWeight} onLineWeightValueChange={changeWeight}
onLineScaleChange={menus.onLineScaleChange} onLineStyleOpen={() => record("onLineStyleOpen")} onLineStyleDetail={menus.onLineStyleDetail}
textStyles={fixtures.textStyles} activeTextStyleId={textStyle?.id} textStyle={textStyle}
onTextStyleSelect={item => { setTextStyle(item); record(`onTextStyleSelect(${item.id})`) }}
onTextStyleOpen={() => record("onTextStyleOpen")} onTextStyleDetail={() => record("onTextStyleDetail")}
dimensionStyles={fixtures.dimensionStyles} activeDimensionStyleId={dimensionStyle?.id} dimensionStyle={dimensionStyle}
onDimensionStyleSelect={item => { setDimensionStyle(item); record(`onDimensionStyleSelect(${item.id})`) }}
onDimensionStyleOpen={() => record("onDimensionStyleOpen")} onDimensionStyleDetail={() => record("onDimensionStyleDetail")}
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
propertyBar={{ selection: propertySelection(example), properties: barProperties, onPropertyChange, readOnly, disabled }}
propertyPanel={{ selection: propertySelection(example), properties: guardedProperties, groups: propertyGroups,
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
}}
onHomeClick={() => record("onHomeClick")} onPrint={() => record("onPrint")} onPopupGuide={() => record("onPopupGuide")}
onMinimize={() => record("onMinimize")} onClose={() => record("onClose")} onWindowMoveStart={() => record("onWindowMoveStart")}
/>
</div>
</div>
</div>
<div className="sw-overview-status">
<span data-preview-size="">{Math.round(size.width)} × {Math.round(size.height)} · 16:9 · 내부 스크롤</span>
<output data-preview-callback="" aria-live="polite" title={callbackLog.join(" → ")}>{callbackLog.join(" → ") || "Callback 없음"}</output>
</div>
</StoryPage>
}