Deploy Storybook / deploy (push) Successful in 1m11s
Labels with an icon now go icon-only one item at a time while the header lacks room (Open, then the panel toggles, 전체 보기 last) instead of all together, and come back as the room returns; collapse into More is unchanged. Verified: Responsive More rows (1440 / 1280 keep 전체 보기 text, 950 / 860 icons, 760 / 520 More), shrink-and-grow restore, no overflow. Not re-run: viewer browser tests.
449 lines
29 KiB
TypeScript
449 lines
29 KiB
TypeScript
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<HTMLAttributes<HTMLElement>, "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, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
|
|
logo?: ReactNode
|
|
/** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is "<logoAlt> 홈". */
|
|
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 = () => (
|
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
|
|
)
|
|
const FolderIcon = () => (
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></svg>
|
|
)
|
|
const ChevronDown = () => (
|
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
)
|
|
|
|
/** 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<string>) => !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<string>, command: (c: SWViewerCommand) => void, owners: Map<string, string>): 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<SWViewerHeaderItem, { type: "menu" }>; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) {
|
|
const ref = useRef<HTMLButtonElement>(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 (
|
|
<>
|
|
<button ref={ref} type="button" className="hm-sw-viewer-header__button" data-kind="menu" data-icon-only={iconOnly === "always" ? "always" : iconOnly ? "" : undefined} data-state={open ? "open" : undefined}
|
|
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
|
|
onClick={() => (open ? setOpen(false) : show("menu"))}
|
|
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
|
|
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
|
|
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
|
|
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
|
|
</button>
|
|
{/* 2026-10-21: header dropdowns use the More face (beige), like every menu of the viewer header. */}
|
|
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} surface="beige" label={item.label} initialFocus={focus}
|
|
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
|
|
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
|
|
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[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 (
|
|
<div role="radiogroup" aria-label={item.label} className="hm-sw-viewer-header__tabs" onKeyDown={onKeyDown}>
|
|
{item.options.map(option => {
|
|
const selected = option.value === item.value
|
|
return (
|
|
<button key={option.value} type="button" role="radio" aria-checked={selected} tabIndex={option.value === current ? 0 : -1}
|
|
className="hm-sw-viewer-header__tab" data-state={selected ? "active" : undefined} title={option.title} disabled={item.disabled || option.disabled}
|
|
onClick={() => { if (!selected) select(option.value) }}>
|
|
{option.label}
|
|
{option.count !== undefined && option.count > 0 && <span className="hm-sw-viewer-header__count">{option.count.toLocaleString()}</span>}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "switch" }>; onCommand: (c: SWViewerCommand) => void }) {
|
|
const id = useId()
|
|
return (
|
|
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
|
|
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
|
|
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
|
|
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
|
|
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
|
|
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
|
|
const toggle = item.type === "toggle"
|
|
return (
|
|
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title ?? (iconOnly ? item.label : undefined)} disabled={item.disabled}
|
|
onClick={() => {
|
|
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<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement, SWViewerHeaderButtonProps>(function SWViewerHeaderButton(
|
|
{ label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref,
|
|
) {
|
|
const kind = pressed === undefined ? "button" : "toggle"
|
|
return (
|
|
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
|
|
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
|
|
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{icon}</SWViewerIcon></span>}
|
|
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
|
|
</button>
|
|
)
|
|
})
|
|
|
|
/**
|
|
* 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<HTMLElement, SWViewerHeaderProps>(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<HTMLElement | null>(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<string, string>()
|
|
const more = moreEntries(groups, collapsed, command, owners)
|
|
const moreRef = useRef<HTMLButtonElement>(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 (
|
|
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
|
|
{items.map(item => <Item key={item.id} item={item} compact={compact.has(item.id)} onCommand={command} />)}
|
|
</div>
|
|
)
|
|
}
|
|
const name = documentTitle || documentPlaceholder
|
|
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
|
|
const brandContent = (
|
|
<>
|
|
<svg className="hm-sw-viewer-header__brand-shape" viewBox="0 0 88 52" preserveAspectRatio="xMaxYMid slice" aria-hidden="true" focusable="false">
|
|
<defs>
|
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="var(--hm-sw-viewer-brand-top)" />
|
|
<stop offset="45%" stopColor="var(--hm-sw-viewer-brand-middle)" />
|
|
<stop offset="100%" stopColor="var(--hm-sw-viewer-brand-bottom)" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="M 0 0 H 88 L 73 44 Q 70 52 62 52 H 0 Z" fill={`url(#${gradientId})`} />
|
|
<path d="M 88 0 L 73 44 Q 70 52 62 52 H 0" fill="none" stroke="var(--hm-sw-viewer-brand-middle)" vectorEffect="non-scaling-stroke" />
|
|
</svg>
|
|
{mark && <span className="hm-sw-viewer-header__brand-mark" aria-hidden="true">{mark}</span>}
|
|
</>
|
|
)
|
|
return (
|
|
<header ref={node => { 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
|
|
? <SWHeaderMore className="hm-sw-viewer-header__logo-menu" align="start" title={logoMenu?.title} label={logoMenu?.title ?? (logoAlt ? `${logoAlt} 메뉴` : "메뉴")}
|
|
open={openMenu === "logo"} onOpenChange={menuOpenChange("logo")}
|
|
items={logoItems.map(item => ({
|
|
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 }) => (
|
|
<button ref={ref} type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} data-state={trigger["aria-expanded"] ? "open" : undefined}
|
|
aria-label={logoAlt ? `${logoAlt} 메뉴` : "메뉴"} title={logoAlt} {...trigger}>{brandContent}</button>
|
|
)} />
|
|
: onHomeClick
|
|
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
|
|
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
|
|
{onDocumentOpen && (() => {
|
|
const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID)
|
|
return (
|
|
<SWToolButton appearance="document" className="hm-sw-viewer-header__open" data-document-open="" data-icon-only={iconOnly ? "" : undefined}
|
|
aria-label={iconOnly ? label : undefined} title={iconOnly ? label : undefined}
|
|
onClick={() => { onDocumentOpen(); command({ id: "open", itemId: "document" }) }}>
|
|
<span className="hm-sw-viewer-header__open-icon" aria-hidden="true"><SWViewerIcon><FolderIcon /></SWViewerIcon></span>
|
|
{!iconOnly && <span className="hm-sw-viewer-header__open-label">{label}</span>}
|
|
</SWToolButton>
|
|
)
|
|
})()}
|
|
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면" data-list={!documentNode && documents ? "" : undefined}>
|
|
{documentNode ?? (documents
|
|
? <SWHeaderDocumentTitle title={name} label="도면" documents={documents} activeDocumentId={activeDocumentId} onDocumentSelect={onDocumentSelect}
|
|
showNew={false} showOpen={false} menuOpen={openMenu === "document"} onMenuOpenChange={menuOpenChange("document")} />
|
|
: <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>)}
|
|
</div>
|
|
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
|
|
{start.map(renderGroup)}
|
|
{more.length > 0 && <>
|
|
<button ref={moreRef} type="button" className="hm-sw-viewer-header__button hm-sw-viewer-header__more" data-icon-only="always" data-state={moreOpen ? "open" : undefined}
|
|
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
|
|
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
|
|
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
|
|
<span className="hm-sw-viewer-header__icon"><SWViewerIcon><MoreIcon /></SWViewerIcon></span>
|
|
</button>
|
|
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
|
|
onEntrySelect={entry => {
|
|
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) && <div className="hm-sw-viewer-header__actions">{end.map(renderGroup)}{trailing}</div>}
|
|
</div>
|
|
</header>
|
|
)
|
|
})
|