import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react" import { SWViewerMenu } from "./SWViewerMenu" import { SWViewerSwitch } from "./SWViewerControls" import { SWViewerIcon } from "./SWViewerIcon" import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types" import "../../theme/sw-viewer.css" export interface SWViewerHeaderProps extends Omit, "title"> { /** * Logo image URL of the program (same prop as SWHeader), max 24px high on the slanted green cap. * The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and the cap * shrinks to its slanted edge. */ logoSrc?: string /** Logo element (inline SVG, , ...). Takes precedence over `logoSrc` (same prop as SWHeader). */ logo?: ReactNode /** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is " 홈". */ logoAlt?: string /** Makes the logo cap a button (e.g. back to the normal SW mode). Same prop as SWHeader. */ onHomeClick?: () => void /** Open file name. `null` / empty shows `documentPlaceholder`. */ documentTitle?: string | null /** Default `열린 도면 없음`. */ documentPlaceholder?: string /** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */ documentTooltip?: string /** Replaces the file-name text (the dark document well stays). */ document?: ReactNode /** Menu data, in order. Groups with `placement="end"` form the trailing action area. */ groups: SWViewerHeaderGroup[] /** Free content after the trailing groups (status, info button, ...). */ trailing?: ReactNode /** Every command of the header and its dropdowns (after the item's own callback). */ onCommand?: (command: SWViewerCommand) => void /** Accessible name of the More button. Default `추가 도구`. */ moreLabel?: string /** Accessible name of the object surface. Default `도면 작업 도구`. */ toolbarLabel?: string } const MoreIcon = () => ( ) const ChevronDown = () => ( ) const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow) /** More entries of the collapsed items, one section per group (menus and tabs get their own section). */ function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map): SWViewerMenuEntry[] { const sections: SWViewerMenuEntry[][] = [] // Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item. const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) }) for (const group of groups) { if (group.hidden) continue const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow) let plain: SWViewerMenuEntry[] = [] const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] } for (const item of collapsed) { if (item.type === "custom") { if (item.menuEntries?.length) { flush(); own(item.menuEntries, item.id); sections.push(item.menuEntries) } continue } if (item.type === "menu") { flush() own(item.entries, item.id) sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.entries.map(entry => (entry.type === "label" || entry.type === "separator" ? entry : { ...entry, disabled: entry.disabled || item.disabled }))]) } else if (item.type === "tabs") { flush() sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.options.map(option => ({ type: "radio" as const, id: `${item.id}:${option.value}`, checked: option.value === item.value, disabled: option.disabled || item.disabled, label: option.count !== undefined && option.count > 0 ? `${option.label} · ${option.count.toLocaleString()}개` : option.label, onSelect: () => { item.onValueChange?.(option.value); command({ id: item.id, itemId: item.id, value: option.value }) }, }))]) } else if (item.type === "button") { plain.push({ type: "item", id: item.id, label: item.label, icon: item.icon, disabled: item.disabled, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) } }) } else { const checked = item.type === "toggle" ? item.pressed : item.checked plain.push({ type: "checkbox", id: item.id, label: item.label, checked, disabled: item.disabled, onCheckedChange: next => { if (item.type === "toggle") item.onPressedChange?.(next); else item.onCheckedChange?.(next) command({ id: item.id, itemId: item.id, value: next }) } }) } } flush() } return sections.flatMap((section, index) => (index ? [{ type: "separator" as const, id: `more-sep-${index}` }, ...section] : section)) } function MenuButton({ item, iconOnly, onCommand }: { item: Extract; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) { const ref = useRef(null) const [open, setOpen] = useState(false) const [focus, setFocus] = useState<"first" | "menu">("menu") const menuId = useId() const show = (how: "first" | "menu") => { setFocus(how); setOpen(true) } return ( <> onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} /> ) } function Tabs({ item, onCommand }: { item: Extract; onCommand: (c: SWViewerCommand) => void }) { const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) } const onKeyDown = (event: KeyboardEvent) => { const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"] if (!keys.includes(event.key)) return const buttons = Array.from(event.currentTarget.querySelectorAll('[role="radio"]:not(:disabled)')) const index = buttons.indexOf(document.activeElement as HTMLButtonElement) if (index < 0) return event.preventDefault() const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length buttons[next].focus() buttons[next].click() } const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value return (
{item.options.map(option => { const selected = option.value === item.value return ( ) })}
) } function SwitchField({ item, onCommand }: { item: Extract; onCommand: (c: SWViewerCommand) => void }) { const id = useId() return (
{ item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
) } function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) { if (item.type === "custom") return <>{item.render()} const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow) if (item.type === "menu") return if (item.type === "tabs") return if (item.type === "switch") return const toggle = item.type === "toggle" return ( { if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) } else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) } }} /> ) } export interface SWViewerHeaderButtonProps extends Omit, "children"> { /** Visible text, or the accessible name when `iconOnly`. */ label: string /** 16px icon supplied by the program. */ icon?: ReactNode /** * `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown * without its label (what `iconOnlyBelow` does). Default false. */ iconOnly?: boolean | "compact" /** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */ pressed?: boolean } /** * The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal * SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed. */ export const SWViewerHeaderButton = forwardRef(function SWViewerHeaderButton( { label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref, ) { const kind = pressed === undefined ? "button" : "toggle" return ( ) }) /** * SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green * object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown * by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every * action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode. */ export const SWViewerHeader = forwardRef(function SWViewerHeader( { logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode, groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest }, forwardedRef, ) { const rootRef = useRef(null) const [width, setWidth] = useState(1920) const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}` useLayoutEffect(() => { const root = rootRef.current if (!root) return const update = () => setWidth(root.getBoundingClientRect().width) update() const observer = new ResizeObserver(update) observer.observe(root) return () => observer.disconnect() }, []) const command = (value: SWViewerCommand) => onCommand?.(value) const owners = new Map() const more = moreEntries(groups, width, command, owners) const moreRef = useRef(null) const [moreOpen, setMoreOpen] = useState(false) const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu") const renderGroup = (group: SWViewerHeaderGroup) => { const items = group.items.filter(item => isShown(item, width)) if (group.hidden || !items.length) return null return (
{items.map(item => )}
) } const start = groups.filter(group => group.placement !== "end") const end = groups.filter(group => group.placement === "end") const name = documentTitle || documentPlaceholder const mark = logo ?? (logoSrc ? : null) const brandContent = ( <> {mark && } ) return (
{ rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }} className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}> {onHomeClick ? :
{brandContent}
}
{documentNode ?? {name}}
{start.map(renderGroup)} {more.length > 0 && <> { const owner = owners.get(entry.id) if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined }) }} /> } {(end.length > 0 || trailing) &&
{end.map(renderGroup)}{trailing}
}
) })