import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react" import { moveRovingFocus } from "../shared/roving-focus" import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle" import { SWHeaderMore } from "../sw-header/components/SWHeaderMore" import type { SWHeaderDocument } from "../sw-header/types" import { SWToolButton } from "../sw-tool/SWTool" 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). Takes precedence over `documents`. */ document?: ReactNode /** * Logo menu (2026-10-21): the logo cap opens a file menu flush under the header (SWHeaderMore behavior: Escape, * outside click, arrow keys; ArrowDown opens it on its first item and focus returns to the logo) with the face, colors, * size, spacing and type of the other viewer header dropdowns (the More face). `title` adds a * title row directly above the items (title + items are one group: no divider), shown only when an item follows; * without a title only the items are listed. `icon`: 16px icon left of the label (SWViewerMenu icon slot). * Each item runs its `onSelect`, then `onCommand` with `{ id, itemId: id }`. Takes precedence over `onHomeClick`; * with no visible item the cap keeps the plain logo / `onHomeClick`. Never open together with the document list. */ logoMenu?: { title?: string items: { id: string; label: string; icon?: ReactNode; value?: string; checked?: boolean; disabled?: boolean; hidden?: boolean; onSelect?: () => void }[] } /** * Document list below the file name (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle) * without + New and Open (file open is the header button, `onDocumentOpen`). The button shows `documentTitle` (or `documentPlaceholder`); the list * is at least as wide as the file-name button and the SWHeader list, wider for longer rows. Omit to keep the plain * file name. */ documents?: SWHeaderDocument[] /** Id of the current document in `documents` (its name in #FFC600; the list order never changes). */ activeDocumentId?: string /** A document row was chosen. The host loads it and updates `documentTitle` / `activeDocumentId`. */ onDocumentSelect?: (document: SWHeaderDocument) => void /** * File open button in the header (2026-10-21), like SWHeader `documentOpenPlacement="header"`: the SWHeader file-name * surface (SWToolButton `appearance="document"`) right after the logo, with a bordered folder icon and the label. * Shown only with this callback; the document list never shows Open or + New in the viewer. When the header has no * room it shows the icon only (label as tooltip and accessible name, measured like the other items), never in More. * Runs the callback, then `onCommand` with `{ id: "open", itemId: "document" }`. */ onDocumentOpen?: () => void /** Label of that button. Default `Open`. */ documentOpenLabel?: string /** 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 FolderIcon = () => ( ) const ChevronDown = () => ( ) /** Step id of the header Open button (icon only in stage 2). */ const OPEN_ID = ":document-open" const isShown = (item: { id: string; hidden?: boolean }, collapsed: ReadonlySet) => !item.hidden && !collapsed.has(item.id) /** * Measured responsive steps (2026-10-21), applied one at a time while the header has no room left and undone in reverse * order once the room a step freed is back: 1. items with an `icon` and `iconOnlyBelow` show their icon only one by one * (label kept as tooltip and accessible name); 2. `collapseBelow` items move into More one by one, the higher value first and, * for the same value, the rightmost first. No fixed width decides a step: the values only give the order. */ type Step = { kind: "icons"; ids: string[] } | { kind: "collapse"; id: string } // 2026-10-22 (user request): labels hide one item at a time (not all at once), so a label with room stays (e.g. 전체 보기 // at 1440 / 1280): the higher `iconOnlyBelow` first, the rightmost first for the same value; extra icons (Open) first. function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] { const icons: { id: string; rank: number; order: number }[] = extraIcons.map((id, order) => ({ id, rank: Infinity, order })) const collapse: { id: string; rank: number; order: number }[] = [] groups.filter(group => !group.hidden).flatMap(group => group.items).forEach((item, order) => { if (item.hidden) return if (item.type !== "custom" && item.icon && !item.iconOnly && item.iconOnlyBelow !== undefined) icons.push({ id: item.id, rank: item.iconOnlyBelow, order: order + extraIcons.length }) if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order }) }) const byRank = (a: { rank: number; order: number }, b: { rank: number; order: number }) => b.rank - a.rank || b.order - a.order icons.sort(byRank) collapse.sort(byRank) return [...icons.map(item => ({ kind: "icons" as const, ids: [item.id] })), ...collapse.map(item => ({ kind: "collapse" as const, id: item.id }))] } /** * Room left in the header row (negative: missing) with every part at its natural width: the file name at its text * width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and * the trailing area without its auto margin (`data-measuring`, sw-viewer.css). Read and reset in one layout effect, * never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More. */ function measureRoom(root: HTMLElement) { root.setAttribute("data-measuring", "") const style = getComputedStyle(root), box = root.getBoundingClientRect() const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) for (const child of Array.from(root.children)) { const childStyle = getComputedStyle(child) if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight)) } root.removeAttribute("data-measuring") return end - used } /** More entries of the collapsed items, one section per group (menus and tabs get their own section). */ function moreEntries(groups: SWViewerHeaderGroup[], collapsedIds: ReadonlySet, 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 && collapsedIds.has(item.id)) 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 ( <> {/* 2026-10-21: header dropdowns use the More face (beige), like every menu of the viewer header. */} 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) => { moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll('[role="radio"]:not(:disabled)')), { vertical: true, select: true }) } 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, compact, onCommand }: { item: SWViewerHeaderItem; compact: boolean; onCommand: (c: SWViewerCommand) => void }) { if (item.type === "custom") return <>{item.render()} const iconOnly = !!item.icon && (item.iconOnly ? "always" : compact) 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. Responsive by the measured room of the * header row, not by fixed widths (2026-10-21): full items while they fit, then icons only (`iconOnlyBelow` items with * an icon), then `collapseBelow` items move into the More dropdown one by one; they come back in reverse order once * the room is there. The numbers only set the order. 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, logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel, groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest }, forwardedRef, ) { const rootRef = useRef(null) // Header width: a resize re-renders, and the layout pass below measures again. const [, 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 start = groups.filter(group => group.placement !== "end") const end = groups.filter(group => group.placement === "end") // Measured responsive steps (see responsiveSteps): `level` steps are applied. Each layout pass measures the room; a // missing room applies the next step, and the last step is undone once the room it freed (measured right after it was // applied) is there again. The passes are synchronous layout effects, so only the settled layout is painted. const steps = responsiveSteps([...start, ...end], onDocumentOpen ? [OPEN_ID] : []) const [level, setLevel] = useState(0) const applied = steps.slice(0, level) const collapsed = new Set(applied.flatMap(step => (step.kind === "collapse" ? [step.id] : []))) const compact = new Set(applied.flatMap(step => (step.kind === "icons" ? step.ids : []))) const freedRef = useRef<{ before: number; room?: number }[]>([]) // Steps or texts that change widths start the measurement over (file name, labels, items shown). const layoutKey = JSON.stringify([documentTitle, documentPlaceholder, documentOpenLabel, !!onDocumentOpen, steps, groups.map(group => [group.hidden, group.items.map(item => [item.id, item.hidden, "label" in item ? item.label : ""])])]) const keyRef = useRef(layoutKey) // Web fonts change text widths without a resize: measure again once they are loaded. const [, setFontsLoaded] = useState(0) useEffect(() => { const fonts = typeof document === "undefined" ? undefined : document.fonts if (!fonts) return let live = true const loaded = () => { if (live) setFontsLoaded(count => count + 1) } fonts.addEventListener?.("loadingdone", loaded) fonts.ready.then(loaded) return () => { live = false; fonts.removeEventListener?.("loadingdone", loaded) } }, []) useLayoutEffect(() => { const root = rootRef.current if (!root) return if (keyRef.current !== layoutKey) { keyRef.current = layoutKey freedRef.current = [] if (level !== 0) return setLevel(0) } if (level > steps.length) return setLevel(steps.length) const room = measureRoom(root) const freed = freedRef.current const last = level > 0 ? freed[level - 1] : undefined if (last && last.room === undefined) last.room = room - last.before if (room < -0.5) { if (level < steps.length) { freed[level] = { before: room } setLevel(level + 1) } } else if (level > 0 && (last?.room === undefined || room >= last.room)) { freed.length = level - 1 setLevel(level - 1) } }) const command = (value: SWViewerCommand) => onCommand?.(value) const owners = new Map() const more = moreEntries(groups, collapsed, command, owners) const moreRef = useRef(null) const [moreOpen, setMoreOpen] = useState(false) const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu") // The logo menu and the document list are never open together (opening one closes the other). const [openMenu, setOpenMenu] = useState<"logo" | "document" | null>(null) const menuOpenChange = (menu: "logo" | "document") => (next: boolean) => setOpenMenu(current => (next ? menu : current === menu ? null : current)) const logoItems = logoMenu?.items.filter(item => !item.hidden) ?? [] const renderGroup = (group: SWViewerHeaderGroup) => { const items = group.items.filter(item => isShown(item, collapsed)) if (group.hidden || !items.length) return null return (
{items.map(item => )}
) } 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}> {logoItems.length > 0 ? ({ id: item.id, label: item.label, icon: item.icon, value: item.value, checked: item.checked, disabled: item.disabled, tier: "action" as const, visible: true, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) }, }))} renderTrigger={({ ref, ...trigger }) => ( )} /> : onHomeClick ? :
{brandContent}
} {onDocumentOpen && (() => { const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID) return ( { onDocumentOpen(); command({ id: "open", itemId: "document" }) }}> {!iconOnly && {label}} ) })()}
{documentNode ?? (documents ? : {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}
}
) })