166 lines
9.3 KiB
TypeScript
166 lines
9.3 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"
|
||
import {
|
||
swFooterAuxIcons, swFooterToolIcons,
|
||
type SWFooterAuxItem, type SWFooterCategory, type SWFooterLayout, type SWFooterTool,
|
||
} from "@hm-design/sw"
|
||
import { PenLine, SquareDashed } from "lucide-react"
|
||
import guideSheet from "./assets/figma-reference/footer/26-9096.png"
|
||
|
||
// SW/Footer fixture: the Figma 26:10097 footer content as public-API data, like a consuming program builds it.
|
||
|
||
/** Figma 도형 commands (26:10111): Line … Hatch, 9 slots. */
|
||
export const DRAW_TOOLS: SWFooterTool[] = [
|
||
{ id: "line", label: "Line", ...swFooterToolIcons.line },
|
||
{ id: "pline", label: "PLine", ariaLabel: "Polyline", ...swFooterToolIcons.pline },
|
||
{ id: "arc", label: "Arc", ...swFooterToolIcons.arc },
|
||
{ id: "circle", label: "Circle", ...swFooterToolIcons.circle },
|
||
{ id: "ellipse", label: "Ellipse", ...swFooterToolIcons.ellipse },
|
||
{ id: "rect", label: "Rect...", ariaLabel: "Rectangle", ...swFooterToolIcons.rect },
|
||
{ id: "poly", label: "Poly", ariaLabel: "Polygon", ...swFooterToolIcons.poly },
|
||
{ id: "point", label: "Point", ...swFooterToolIcons.point },
|
||
{ id: "hatch", label: "Hatch", ...swFooterToolIcons.hatch },
|
||
]
|
||
|
||
/** Figma category row (text_도형 … text_구조배근). Only 도형 has Figma commands; 수정 shows the NCopy component (26:9739). */
|
||
export const CATEGORIES: SWFooterCategory[] = [
|
||
{ id: "shape", label: "도형", tools: DRAW_TOOLS },
|
||
{ id: "modify", label: "수정", tools: [{ id: "ncopy", label: "NCopy", ariaLabel: "Multiple copy", ...swFooterToolIcons.ncopy }] },
|
||
{ id: "edit", label: "편집", tools: [] },
|
||
{ id: "text", label: "문자", tools: [] },
|
||
{ id: "layer", label: "레이어", tools: [] },
|
||
{ id: "block", label: "블록", tools: [] },
|
||
{ id: "view", label: "뷰", tools: [] },
|
||
{ id: "select", label: "선택/조회", tools: [] },
|
||
{ id: "road", label: "도로/횡단", tools: [] },
|
||
{ id: "structure", label: "구조/배근", tools: [] },
|
||
]
|
||
|
||
/** Figma 26:10104 (3 × 2). */
|
||
export const AUX_ITEMS: SWFooterAuxItem[] = [
|
||
{ id: "objectSnap", label: "객체스냅", icon: swFooterAuxIcons.objectSnap, pressed: false },
|
||
{ id: "selectionEffect", label: "선택효과", icon: swFooterAuxIcons.selectionEffect, pressed: false },
|
||
{ id: "orthogonal", label: "직각교차", icon: swFooterAuxIcons.orthogonal, pressed: false },
|
||
{ id: "gridSnap", label: "격자스냅", icon: swFooterAuxIcons.gridSnap, pressed: false },
|
||
{ id: "gridBackground", label: "격자배경", icon: swFooterAuxIcons.gridBackground, pressed: false },
|
||
{ id: "lineWeight", label: "선가중치", icon: swFooterAuxIcons.lineWeight, pressed: false },
|
||
]
|
||
|
||
/**
|
||
* Reference screen (2026-10-20, src/sw/assets/footer-reference): the program's own content in the same footer.
|
||
* Labels, categories and the extra command are program data; WIPEOUT and 객체스냅추적 have no Figma icon, so the
|
||
* Story passes its own (lucide) icons the way a program would.
|
||
*/
|
||
const lucide = (node: ReactNode) => <span className="sw-footer-story-lucide">{node}</span>
|
||
export const REFERENCE_TOOLS: SWFooterTool[] = [
|
||
{ id: "line", label: "LINE", ...swFooterToolIcons.line },
|
||
{ id: "pline", label: "PLINE", ariaLabel: "Polyline", ...swFooterToolIcons.pline },
|
||
{ id: "arc", label: "ARC", ...swFooterToolIcons.arc },
|
||
{ id: "circle", label: "CIRCLE", ...swFooterToolIcons.circle },
|
||
{ id: "ellipse", label: "ELLIPSE", ...swFooterToolIcons.ellipse },
|
||
{ id: "rect", label: "RECTA...", ariaLabel: "Rectangle", ...swFooterToolIcons.rect },
|
||
{ id: "poly", label: "POLYGON", ...swFooterToolIcons.poly },
|
||
{ id: "point", label: "POINT", ...swFooterToolIcons.point },
|
||
{ id: "hatch", label: "HATCH", ...swFooterToolIcons.hatch },
|
||
{ id: "wipeout", label: "WIPEOUT", icon: lucide(<SquareDashed size={16} />) },
|
||
]
|
||
export const REFERENCE_CATEGORIES: SWFooterCategory[] = [
|
||
{ id: "shape", label: "도형", tools: REFERENCE_TOOLS },
|
||
...["수정", "편집", "문자", "치수/연산", "블록", "레이어", "뷰", "선택/조회", "모델링", "선형/횡단", "구조/배근"]
|
||
.map((label, index) => ({ id: `c${index}`, label, tools: index === 0 ? CATEGORIES[1].tools : [] })),
|
||
]
|
||
export const REFERENCE_AUX: SWFooterAuxItem[] = [
|
||
{ id: "objectSnap", label: "객체스냅", icon: swFooterAuxIcons.objectSnap, pressed: false },
|
||
{ id: "orthogonal", label: "직각교차", icon: swFooterAuxIcons.orthogonal, pressed: false },
|
||
{ id: "lineWeight", label: "선가중치", icon: swFooterAuxIcons.lineWeight, pressed: false },
|
||
{ id: "gridBackground", label: "격자배경", icon: swFooterAuxIcons.gridBackground, pressed: true },
|
||
{ id: "gridSnap", label: "격자스냅", icon: swFooterAuxIcons.gridSnap, pressed: false },
|
||
{ id: "objectTrack", label: "객체스냅추적", icon: lucide(<PenLine size={14} />), pressed: true },
|
||
]
|
||
export const REFERENCE_HISTORY = [
|
||
"Raster image support loaded.",
|
||
"Command: PSLTSCALE",
|
||
"Enter new value for PSLTSCALE <ON>: _ON",
|
||
"Command: VSBACKGROUNDS",
|
||
"Enter new value for VSBACKGROUNDS <1>: 1",
|
||
]
|
||
|
||
export const LAYOUTS: SWFooterLayout[] = [
|
||
{ id: "layout3", label: "Layout 03" },
|
||
{ id: "layout2", label: "Layout 02" },
|
||
{ id: "layout1", label: "Layout 01" },
|
||
]
|
||
|
||
export const HISTORY = [
|
||
"Fillet: [Polyline/Radius/Trim/Settings/Multiple]/<Select first entity>: R",
|
||
"Fillet radius <0.0000>: 6",
|
||
"Current settings: mode = NOTRIM, radius = 6.0000",
|
||
]
|
||
|
||
/**
|
||
* The six frames of guide_바로가기 등록 (26:9096 Variant5 → 6 → 4 → 9 → 8 → 9): the guide "video" of the empty slot,
|
||
* cut from the Figma export of the set (each frame's 194 × 111 preview at +6, +6).
|
||
*/
|
||
const GUIDE_FRAMES = [[20, 52], [20, 237], [23, 422], [23, 599], [19, 807], [22, 1001]] as const
|
||
export function GuideFrame({ index }: { index: number }) {
|
||
const [x, y] = GUIDE_FRAMES[index % GUIDE_FRAMES.length]
|
||
return <span className="sw-footer-story-guide-frame" style={{ backgroundImage: `url(${guideSheet})`, backgroundPosition: `-${x + 6}px -${y + 6}px` }} aria-hidden="true" />
|
||
}
|
||
/** Cycles the frames like the guide video (Storybook only; a program passes its own <video>). */
|
||
export function GuideMovie({ interval = 900 }: { interval?: number }) {
|
||
const [frame, setFrame] = useState(0)
|
||
useEffect(() => { const timer = window.setInterval(() => setFrame(value => value + 1), interval); return () => window.clearInterval(timer) }, [interval])
|
||
return <GuideFrame index={frame} />
|
||
}
|
||
|
||
/** Pins :hover visuals on descendants matching `hover` (data-sb-hover, mirrored by .storybook/pseudo-states.ts). */
|
||
export function Pin({ hover, children }: { hover?: string; children: ReactNode }) {
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
useLayoutEffect(() => {
|
||
if (!hover) return
|
||
const nodes = Array.from(ref.current?.querySelectorAll(hover) ?? [])
|
||
nodes.forEach(node => node.setAttribute("data-sb-hover", ""))
|
||
return () => nodes.forEach(node => node.removeAttribute("data-sb-hover"))
|
||
})
|
||
return <div ref={ref} className="sw-footer-story-pin">{children}</div>
|
||
}
|
||
|
||
/** Footer demo state shared by the interactive Stories. */
|
||
export function useFooterDemo({ aux: initialAux = AUX_ITEMS, layouts: initialLayouts = LAYOUTS, history: initialHistory = HISTORY } = {}) {
|
||
const [categoryId, setCategoryId] = useState("shape")
|
||
const [activeToolId, setActiveToolId] = useState<string | null>(null)
|
||
const [aux, setAux] = useState(initialAux)
|
||
const [layouts, setLayouts] = useState(initialLayouts)
|
||
const [space, setSpace] = useState("model")
|
||
const [history, setHistory] = useState(initialHistory)
|
||
const [log, setLog] = useState<string[]>([])
|
||
const note = (line: string) => setLog(lines => [...lines.slice(-5), line])
|
||
return {
|
||
categoryId, activeToolId, aux, layouts, space, history, log,
|
||
onCategoryChange: (id: string) => { setCategoryId(id); setActiveToolId(null); note(`category:${id}`) },
|
||
onToolSelect: (id: string) => {
|
||
setActiveToolId(current => (current === id ? null : id))
|
||
setHistory(lines => [...lines, `Command: ${id.toUpperCase()}`])
|
||
note(`tool:${id}`)
|
||
},
|
||
onSlotAdd: (index: number, category: string) => note(`slotAdd:${category}#${index}`),
|
||
onCategoryAdd: () => note("categoryAdd"),
|
||
onAuxPressedChange: (id: string, pressed: boolean) => { setAux(items => items.map(item => (item.id === id ? { ...item, pressed } : item))); note(`aux:${id}=${pressed}`) },
|
||
onSpaceChange: (value: string) => { setSpace(value); note(`space:${value}`) },
|
||
onLayoutAdd: () => {
|
||
setLayouts(items => {
|
||
const next = Math.max(0, ...items.map(item => Number(item.id.replace("layout", "")) || 0)) + 1
|
||
return [{ id: `layout${next}`, label: `Layout ${String(next).padStart(2, "0")}` }, ...items]
|
||
})
|
||
note("layoutAdd")
|
||
},
|
||
onLayoutRemove: (id: string) => { setLayouts(items => items.filter(item => item.id !== id)); setSpace(value => (value === id ? "model" : value)); note(`layoutRemove:${id}`) },
|
||
onLayoutRename: (id: string, label: string) => { setLayouts(items => items.map(item => (item.id === id ? { ...item, label } : item))); note(`layoutRename:${id}=${label}`) },
|
||
onCommand: (command: string) => { if (command.trim()) setHistory(lines => [...lines, `Command: ${command}`]); note(`command:${command}`) },
|
||
onSide: (name: string) => () => note(name),
|
||
}
|
||
}
|
||
|
||
export function Log({ lines }: { lines: string[] }) {
|
||
return <p className="sw-story-log" aria-live="polite" data-callback-log="">Callback: {lines.length ? lines.join(" → ") : "없음"}</p>
|
||
}
|