import { forwardRef, useEffect, useLayoutEffect, Fragment, type ReactNode, useId, useRef, useState } from "react" import { SWHeaderActions } from "./components/SWHeaderActions" import { SWHeaderColorMenu } from "./components/SWHeaderColorMenu" import { SWHeaderDocumentTitle } from "./components/SWHeaderDocumentTitle" import { SWHeaderColorControl, SWHeaderDimensionStyle, SWHeaderLayerControl, SWHeaderLineStyle, SWHeaderTextStyle, } from "./components/SWHeaderFields" import { SWHeaderHome } from "./components/SWHeaderHome" import { SWHeaderLayerMenu } from "./components/SWHeaderLayerMenu" import { SWHeaderLineStyleMenu } from "./components/SWHeaderLineStyleMenu" import { SWHeaderMore, type SWHeaderMoreItem } from "./components/SWHeaderMore" import { SWHeaderProperties } from "./components/SWHeaderProperties" import { SWHeaderPropertyBar } from "./components/SWHeaderPropertyBar" import { SWHeaderCommitPresence } from "./components/SWHeaderInput" import { SWHeaderKGuide } from "./components/SWHeaderKGuide" import printIcon from "../../assets/print.svg" import printHover from "../../assets/print-hover.svg" import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel" import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus" import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls" import { SWToolButton } from "../sw-tool/SWTool" import { SWHomeMenu } from "../sw-home-menu/SWHomeMenu" import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderHomeMenuChangeReason, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types" const defaults = { layer: { name: "0" }, color: { value: "ByLayer", swatch: "#ffffff" }, lineStyle: { name: "ByLayer", weight: "ByLayer", scale: "1.00" }, textStyle: { font: "Arial", name: "Standard" }, dimensionStyle: { name: "Standard" }, } /** `data-menu` of each field (its Figma variant name). */ const menuSlot: Record = { layer: "layer", color: "color", lineStyle: "line-style", textStyle: "text-style", dimensionStyle: "dimension-style", } const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[] const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id })) const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"] const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"] const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot /** Header width from which nothing moves into More (2026-10-21): the header compresses instead. Below it, as before. */ const MORE_BELOW = 760 /** File name width from 760px up: Figma 310px, smallest 96px (sw.css .hm-sw-header__document). */ const DOCUMENT_MAX = 310 const DOCUMENT_MIN = 96 /** Field pictures that hide one at a time while the header lacks room (sw.css [data-compact]). */ const COMPACT_LEVELS = 5 /** From this header width the Figma field boxes apply (sw.css "Responsive"). */ const FIGMA_LAYOUT_FROM = 1800 /** * Order in which visible parts move into More when the measured object area has no room left (on top of the * breakpoints): the breakpoint tier order — line / text / dimension style, 그리기모드, the property block, color, * print / K — and within a tier the rightmost part first. The representative field never moves. */ const squeezeRank: Partial> = { lineStyle: 0, textStyle: 0, dimensionStyle: 0, drawingMode: 1, details: 2, propertyBar: 2, propertyPanel: 2, color: 3, print: 4, popupGuide: 4, } /** * Room left in the object area (negative: missing) when nothing in it shrinks, grows or takes free space through an * auto margin (`data-measuring`, see sw.css "Measured More"). Read and reset in one layout effect, never painted. * Only in-flow children count: dropdowns, tooltips and other positioned layers never change the result. */ function measureRoom(header: HTMLElement, area: HTMLElement) { header.setAttribute("data-measuring", "") const style = getComputedStyle(area) const end = area.getBoundingClientRect().right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) let used = area.getBoundingClientRect().left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) for (const child of Array.from(area.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)) } header.removeAttribute("data-measuring") return end - used } /** * Menu order: listed known ids first (duplicates and unknown ids ignored), missing ids after them in * the default order. Menus only swap places among the slots menus already take; other parts never move. */ function orderMenus(items: SWHeaderControlConfig[], menuOrder?: readonly SWHeaderMenuId[]) { if (!menuOrder) return items const listed = menuOrder.filter((id, index) => isMenu(id) && menuOrder.indexOf(id) === index) const rank = [...listed, ...defaultMenuOrder.filter(id => !listed.includes(id))] const menus = items.filter(item => isMenu(item.id)).sort((a, b) => rank.indexOf(a.id as SWHeaderMenuId) - rank.indexOf(b.id as SWHeaderMenuId)) let next = 0 return items.map(item => (isMenu(item.id) ? menus[next++] : item)) } /** * SW Header (Figma node 26:8944, 1920 x 49). Pure presentation: values are props, * user actions are callbacks, window behavior belongs to the host program. */ export const SWHeader = forwardRef(function SWHeader( { documentTitle, controls, hiddenControls, menuOrder, documents, documentMenuOpen, onDocumentMenuOpenChange, onDocumentSelect, activeDocumentId, onDocumentClose, onDocumentNew, onDocumentOpen, documentIcon, documentNewLabel, documentOpenLabel, documentOpenPlacement = "menu", leading, drawingModeLabel = "그리기모드", layer = defaults.layer, layers, activeLayerId, layerMenuOpen, onLayerMenuOpenChange, onLayerSelect, onLayerVisibilityChange, onLayerFreezeChange, onLayerLockChange, onLayerDetail, color = defaults.color, lineStyle = defaults.lineStyle, textStyle = defaults.textStyle, dimensionStyle = defaults.dimensionStyle, colors, userColors, colorMode, colorMenuOpen, onColorMenuOpenChange, onColorSelect, onColorModeChange, onColorInput, onColorPick, onColorDetail, lineTypes, lineWeights, activeLineTypeId, activeLineWeightId, lineStyleMenuOpen, onLineStyleMenuOpenChange, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onLineScaleChange, onLineStyleDetail, textStyles, activeTextStyleId, textStyleMenuOpen, onTextStyleMenuOpenChange, onTextStyleSelect, onTextStyleDetail, dimensionStyles, activeDimensionStyleId, dimensionStyleMenuOpen, onDimensionStyleMenuOpenChange, onDimensionStyleSelect, onDimensionStyleDetail, activeMenu = null, propertyBarOpen, defaultPropertyBarOpen = false, propertyBar, onPropertyBarOpenChange, propertyPanelOpen, propertyPanel, onPropertyPanelOpenChange, disabled = false, tools, homeMenu, homeMenuOpen, defaultHomeMenuOpen = false, onHomeMenuOpenChange, logo, logoSrc, logoAlt, onHomeClick, onDocumentClick, onLayerOpen, onColorOpen, onLineStyleOpen, onTextStyleOpen, onDimensionStyleOpen, onPropertyBarToggle, onPropertyPanelToggle, onPrint, onPopupGuide, onWindowMoveStart, onMinimize, onClose, className, ...rest }, ref, ) { // The logo is never in this list: it is always shown. Hidden parts are unmounted (no space, no popup, no callback). const removed = new Set(hiddenControls ?? []) const shown = (id: SWHeaderHideableControlId) => !removed.has(id) const seen = new Set() const configuration = orderMenus((controls ?? defaultControls).filter(item => item.visible !== false && shown(item.id) && !seen.has(item.id) && !!seen.add(item.id)), menuOrder) const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id) // Default design with removed object parts: same structure and breakpoints, but the script decides what // moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only. // Sub-items live inside a part and never change the layout mode. Parts measured into More also need the script. const [squeezed, setSqueezed] = useState([]) const scripted = controls !== undefined || squeezed.length > 0 || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId)) const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") } const presentRef = useRef(new Set(configuration.map(item => item.id))) presentRef.current = new Set(configuration.map(item => item.id)) const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback const [internalBarOpen, setInternalBarOpen] = useState(defaultPropertyBarOpen) const barOpen = !disabled && enabled("propertyBar") && (propertyBarOpen ?? internalBarOpen) const toggleBar = () => { if (!enabled("propertyBar") || disabled) return const next = !barOpen if (propertyBarOpen === undefined) setInternalBarOpen(next) onPropertyBarToggle?.() onPropertyBarOpenChange?.(next) } // Uncontrolled open dropdown (one at a time). A controlled `*MenuOpen` prop takes precedence. const [openMenu, setOpenMenu] = useState(null) // Representative slot (UI state only, never program data): a field chosen in More takes the layer // field's place while it is in More at the current width (CSS decides, see sw.css "Representative slot"). const [promoted, setPromoted] = useState(null) const focusRef = useRef(null) const fieldsRef = useRef(null) const panelAnchorRef = useRef(null) const panelId = useId() const barId = useId() const [internalPanelOpen, setInternalPanelOpen] = useState(false) const panelOpen = !disabled && enabled("propertyPanel") && (propertyPanelOpen ?? internalPanelOpen) const setPanelOpen = (next: boolean) => { if (propertyPanelOpen === undefined) setInternalPanelOpen(next) if (next !== panelOpen) onPropertyPanelOpenChange?.(next) if (next) setOpenMenu(null) } const togglePanel = () => { onPropertyPanelToggle?.(); setPanelOpen(!panelOpen) } const closePanel = () => { if (propertyPanelOpen !== undefined && !onPropertyPanelOpenChange) onPropertyPanelToggle?.() setPanelOpen(false) const header = panelAnchorRef.current?.closest(".hm-sw-header") const trigger = header?.querySelector('[data-property-panel-trigger]') const more = header?.querySelector('.hm-sw-header__more-trigger') ;(trigger?.getClientRects().length ? trigger : more)?.focus() } const [headerWidth, setHeaderWidth] = useState(1920) const [, setAreaWidth] = useState(0) const [barOffset, setBarOffset] = useState(0) // 2026-10-23: the property bar strip reaches at least the 속성Bar | 속성창 divider (the 속성Bar toggle when the // divider is not shown), measured like barOffset; null while the toggle is not in the header row (e.g. in More). const [barEnd, setBarEnd] = useState(null) const measureBarEnd = (header: Element) => { const edge = header.querySelector(".hm-sw-header__properties-divider") ?? header.querySelector(".hm-sw-header__properties .hm-sw-header__toggle:not([data-property-panel-trigger])") setBarEnd(edge?.getClientRects().length ? edge.getBoundingClientRect().right - header.getBoundingClientRect().left : null) } // Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room. const openInHeader = documentOpenPlacement === "header" && shown("documentOpen") const leadingRef = useRef(null) const [openCollapsed, setOpenCollapsed] = useState(false) const collapseRef = useRef<{ at: number; width: number } | null>(null) const openShown = openInHeader && !openCollapsed const hasLeading = openShown || (leading !== undefined && leading !== null && leading !== false) useLayoutEffect(() => { const header = fieldsRef.current?.closest(".hm-sw-header") if (!header) return const update = () => { const bounds = header.getBoundingClientRect() setHeaderWidth(bounds.width) // The object area can change alone (leading content, file name, window area): it re-runs the measured More. setAreaWidth(panelAnchorRef.current?.getBoundingClientRect().width ?? 0) setBarOffset((panelAnchorRef.current?.getBoundingClientRect().left ?? bounds.left) - bounds.left) measureBarEnd(header) } const observer = new ResizeObserver(update) update() observer.observe(header) if (panelAnchorRef.current) observer.observe(panelAnchorRef.current) return () => observer.disconnect() }, []) // The divider also moves when the measured More or the fields change inside the same object-area width. useLayoutEffect(() => { const header = fieldsRef.current?.closest(".hm-sw-header") if (barOpen && header) measureBarEnd(header) }) // Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts // --hm-sw-header-leading-width). Open and the other parts move into More by measurement (see "Measured More"). useLayoutEffect(() => { const header = fieldsRef.current?.closest(".hm-sw-header") if (!header) return const leadingWidth = leadingRef.current ? leadingRef.current.getBoundingClientRect().width : 0 const value = hasLeading ? `${leadingWidth + 2}px` : "" if (header.style.getPropertyValue("--hm-sw-header-leading-width") !== value) { if (value) header.style.setProperty("--hm-sw-header-leading-width", value) else header.style.removeProperty("--hm-sw-header-leading-width") } }) // 2026-10-22 (user request): from 760px to 1800px the space left decides, not fixed widths. While the object area lacks // room, the field pictures hide one at a time (data-compact 1-5: 치수 아이콘, 선 그림, 글꼴명, 색상 칩, 레이어 상태 // 아이콘, sw.css), then the file name gives way (310 -> 96px), then the values ellipsize. With room to spare the // pictures stay and the file name keeps 310px. // Measured on every render and again once web fonts finish loading (text widths change, the header size does not). const fitFieldsRef = useRef(() => {}) fitFieldsRef.current = () => { const header = fieldsRef.current?.closest(".hm-sw-header") const area = panelAnchorRef.current const fields = fieldsRef.current const documentBox = header?.querySelector(":scope > .hm-sw-header__bar > .hm-sw-header__document") if (!header || !area || !fields) return const reset = () => { fields.removeAttribute("data-compact"); documentBox?.style.removeProperty("flex-basis") } // From 1800px the fields are the fixed Figma boxes (pictures free no room) and the file name keeps its CSS clamp. if (controls !== undefined || headerWidth < MORE_BELOW || headerWidth >= FIGMA_LAYOUT_FROM) { reset(); return } documentBox?.style.setProperty("flex-basis", `${DOCUMENT_MAX}px`) let level = 0 let room = 0 for (; level <= COMPACT_LEVELS; level++) { fields.setAttribute("data-compact", String(level)) room = measureRoom(header, area) if (room >= 0) break } if (level > COMPACT_LEVELS) fields.setAttribute("data-compact", String(COMPACT_LEVELS)) if (room < 0 && documentBox) documentBox.style.setProperty("flex-basis", `${Math.max(DOCUMENT_MIN, DOCUMENT_MAX + room)}px`) } useLayoutEffect(() => fitFieldsRef.current()) useEffect(() => { const fonts = typeof document === "undefined" ? undefined : document.fonts if (!fonts) return let active = true const refit = () => { if (active) fitFieldsRef.current() } fonts.addEventListener("loadingdone", refit) void fonts.ready.then(refit) return () => { active = false; fonts.removeEventListener("loadingdone", refit) } }, []) // Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick. const homeMenuId = useId() const homeButtonRef = useRef(null) const [internalHomeOpen, setInternalHomeOpen] = useState(defaultHomeMenuOpen) const homeOpen = !!homeMenu && shown("home") && (homeMenuOpen ?? internalHomeOpen) const [internalDocumentOpen, setInternalDocumentOpen] = useState(false) const documentOpenNow = documentMenuOpen ?? internalDocumentOpen const setDocumentOpen = (next: boolean) => { if (documentMenuOpen === undefined) setInternalDocumentOpen(next) onDocumentMenuOpenChange?.(next) } const setHomeOpen = (next: boolean, reason: SWHeaderHomeMenuChangeReason) => { if (homeMenuOpen === undefined) setInternalHomeOpen(next) if (next !== homeOpen) onHomeMenuOpenChange?.(next, reason) if (!next) return // One header layer at a time: the menu covers the fields, so their dropdown and the document menu close. if (openMenu) openers[openMenu].onOpenChange(false) if (documentOpenNow) setDocumentOpen(false) } // Placement from the DOM (never a fixed x): 2px before the object area (Figma 375 vs 377 at 1920) up to the // window area, so it follows the breakpoints, `leading`, the header Open and a narrowed file name. const [homeBox, setHomeBox] = useState({ left: 0, width: 0 }) useLayoutEffect(() => { const header = fieldsRef.current?.closest(".hm-sw-header") homeButtonRef.current = header?.querySelector(":scope > .hm-sw-header__bar > .hm-sw-header__home") ?? null if (!homeOpen || !header || !panelAnchorRef.current) return const origin = header.getBoundingClientRect() // With `controls`, the file name sits inside the object area: start after it so it stays visible. const documentRight = fieldsRef.current?.querySelector(':scope > [data-control="document"]:not([data-hidden])')?.getBoundingClientRect().right const start = Math.max(panelAnchorRef.current.getBoundingClientRect().left, documentRight ?? -Infinity) const end = header.querySelector(":scope > .hm-sw-header__bar > .hm-sw-header__window")?.getBoundingClientRect().left ?? origin.right const next = { left: start - origin.left - 2, width: end - start + 2 } if (Math.abs(next.left - homeBox.left) > 0.1 || Math.abs(next.width - homeBox.width) > 0.1) setHomeBox(next) }) const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot) const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0] const validPromoted = promoted && enabled(promoted) ? promoted : null // 2026-10-21 (user decision): no responsive More from 760px up. Nothing moves out of the header there; narrower headers // keep the field titles (they and the values ellipsize), hide the 그리기모드 text and pictures, then shrink the file // name, then the field values (ellipsis, sw.css "Responsive"). Below 760px // (phones, e.g. 390) the former breakpoints and More are unchanged. const breakpointOverflowAt = (id: SWHeaderControlId, width: number) => width < MORE_BELOW && (id === "color" ? width < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? width < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? width < 1080 : ["print", "popupGuide"].includes(id) ? width < 640 : id === "drawingMode" ? width < 1180 : false) const breakpointOverflow = (id: SWHeaderControlId) => breakpointOverflowAt(id, headerWidth) // Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in // the window area there and keep the breakpoint). Keys of the moved parts are kept in move order. const squeezeGroups = (controls === undefined ? [...menuIds.map(id => ({ key: id as string, ids: [id] as SWHeaderControlId[] })), { key: "drawingMode", ids: ["drawingMode"] as SWHeaderControlId[] }, { key: "properties", ids: ["details", "propertyBar", "propertyPanel"] as SWHeaderControlId[] }] : configuration.map(item => ({ key: item.id as string, ids: [item.id] }))) .map((group, index) => ({ ...group, ids: group.ids.filter(id => enabled(id) && squeezeRank[id] !== undefined), index })) .filter(group => group.ids.length > 0) // Parts a breakpoint brings back while the header grows can be held in More until there is room (2026-10-20). In // the default layout print / K sit in the window area: they are held as one part ("actions", the narrow window // state of sw.css), never squeezed earlier than their breakpoint. const actionIds = (["print", "popupGuide"] as SWHeaderControlId[]).filter(id => enabled(id)) const holdGroups = controls === undefined && actionIds.length > 0 ? [...squeezeGroups, { key: "actions", ids: actionIds, index: squeezeGroups.length }] : squeezeGroups const squeezedIds = new Set(holdGroups.filter(group => squeezed.includes(group.key)).flatMap(group => group.ids)) const isOverflow = (id: SWHeaderControlId) => breakpointOverflow(id) || squeezedIds.has(id) const representative = validPromoted && isOverflow(validPromoted) ? validPromoted : baseMenu const fieldHidden = (id: SWHeaderMenuId) => id !== representative && (isOverflow(id) || (id === baseMenu && representative !== baseMenu)) // Next part to move: shown now, never the representative slot, lowest tier first, rightmost first within a tier. const squeezeCandidates = squeezeGroups .filter(group => !squeezed.includes(group.key) && !group.ids.some(id => id === baseMenu || id === validPromoted || (isMenu(id) ? fieldHidden(id) : isOverflow(id)))) .sort((a, b) => (squeezeRank[a.ids[0]] ?? 0) - (squeezeRank[b.ids[0]] ?? 0) || b.index - a.index) // Removal is structural: close stale internal dropdown state silently, never send data callbacks. const controlKey = configuration.map(item => item.id).join(",") useEffect(() => { if (openMenu && !enabled(openMenu)) setOpenMenu(null) if (promoted && !enabled(promoted)) setPromoted(null) if (!enabled("propertyPanel")) setInternalPanelOpen(false) if (!enabled("propertyBar")) setInternalBarOpen(false) }, [controlKey, openMenu, promoted]) // Measured More (2026-10-19): on top of the breakpoints, parts move into More while the object area has no room for // them (leading content, tools, labels, a side panel) and return once the room they freed is back, so the header // never relies on fixed widths alone. Open next to the logo moves first when the header shrinks. Growing (2026-10-20, // hysteresis fix A): a part its breakpoint brings back (print / K, color, the property block, …) returns only when // the measured room is there, otherwise it waits in More; Open, once back, is not pushed out again while the header // keeps growing. One part per pass; the passes are synchronous layout effects, so only the final layout is painted. // Opening a menu never moves a part: dropdowns and the logo menu are positioned layers, which the measurement ignores. const squeezeRef = useRef(new Map()) // Header width of this pass and of the previous one (growing or shrinking), and the width at which Open came back. const widthRef = useRef({ current: 0, previous: 0 }) const openReturnRef = useRef(null) // Parts that left More at the current width: they are not held again (nor given the More relief) at this width, so // the passes always settle (one hold / return decision per part and width). const releasedRef = useRef<{ width: number; keys: Set }>({ width: 0, keys: new Set() }) useLayoutEffect(() => { const header = fieldsRef.current?.closest(".hm-sw-header") const area = panelAnchorRef.current // Wait for the measured header width (breakpoint state) to be rendered first. if (!header || !area || Math.abs(header.getBoundingClientRect().width - headerWidth) > 0.5) return if (!openInHeader) { collapseRef.current = null if (openCollapsed) return setOpenCollapsed(false) } // No responsive More from 760px up (2026-10-21): the header compresses instead. Below it the measured More runs as // before; parts it moved leave silently once the header is that wide again. if (headerWidth >= MORE_BELOW) { if (squeezed.length) { squeezeRef.current.clear(); return setSqueezed([]) } if (openCollapsed) { collapseRef.current = null; return setOpenCollapsed(false) } return } if (widthRef.current.current !== headerWidth) widthRef.current = { previous: widthRef.current.current, current: headerWidth } const { previous } = widthRef.current if (releasedRef.current.width !== headerWidth) releasedRef.current = { width: headerWidth, keys: new Set() } const released = releasedRef.current.keys const growing = previous > 0 && headerWidth > previous + 0.5 // Shrinking (or below the width where it came back), Open is again the first part to move. const shrinking = previous > 0 && headerWidth < previous - 0.5 if (openReturnRef.current !== null && (openCollapsed || shrinking || headerWidth < openReturnRef.current - 0.5)) openReturnRef.current = null // While Open is in More the header may shrink further: it returns relative to the narrowest width since then. if (openCollapsed && collapseRef.current && headerWidth < collapseRef.current.at) collapseRef.current = { ...collapseRef.current, at: headerWidth } // Parts removed from the configuration, and parts whose breakpoint now keeps them in More anyway, leave the list silently. const valid = squeezed.filter(key => holdGroups.some(group => group.key === key && !group.ids.every(breakpointOverflow))) if (valid.length !== squeezed.length) { for (const key of squeezed) if (!valid.includes(key)) squeezeRef.current.delete(key) return setSqueezed(valid) } const room = measureRoom(header, area) const last = squeezed[squeezed.length - 1] const metrics = last === undefined ? undefined : squeezeRef.current.get(last) // The room a part frees is measured once, right after it moved (More appearing and padding changes included). if (metrics && metrics.room === undefined) metrics.room = room - metrics.before if (room < -0.5) { // Growing: a part its breakpoint just brought back stays in More until the room it needs is there (it returns // through the measured return below), so it never pushes Open or another part out. const held = growing ? holdGroups.find(group => !squeezed.includes(group.key) && !released.has(group.key) && group.ids.some(id => breakpointOverflowAt(id, previous)) && !group.ids.some(breakpointOverflow) && !group.ids.some(id => id === baseMenu || id === validPromoted)) : undefined if (held) { squeezeRef.current.set(held.key, { before: room }) return setSqueezed([...squeezed, held.key]) } // Open that came back while growing keeps its place: another part moves first; Open only when none can. const keepOpen = openReturnRef.current !== null && squeezeCandidates.length > 0 const open = openShown && !keepOpen ? leadingRef.current?.querySelector("[data-document-open]") : null if (open) { collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 } return setOpenCollapsed(true) } const next = squeezeCandidates[0] if (next) { squeezeRef.current.set(next.key, { before: room }) setSqueezed([...squeezed, next.key]) } } else if (openCollapsed && collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) { // Open returns once the header is wider than when it moved by the Open width (no flicker at the edge). It does not // wait for the parts still in More (2026-10-20): it takes its width from the file name, and while the header // keeps growing it keeps its place (a part that needs room waits in More instead). openReturnRef.current = headerWidth setOpenCollapsed(false) } else if (last !== undefined) { // The last part leaving More also removes the More button when nothing else is in it at this width (the room it // freed was measured with More already there, e.g. a part held at a narrower tier). const more = squeezed.length === 1 && !openCollapsed && !released.has(last) && !configuration.some(item => breakpointOverflow(item.id)) ? area.querySelector(":scope > .hm-sw-header__more") : null let relief = 0 if (more) { // Measured, like the room itself: the same synchronous read with More taken out (never painted). more.style.display = "none" relief = measureRoom(header, area) - room more.style.display = "" } if (room + relief >= (metrics?.room ?? Infinity) - 0.5) { released.add(last) squeezeRef.current.delete(last) setSqueezed(squeezed.slice(0, -1)) } } }) const hasMenu: Record = { layer: layers !== undefined, color: colors !== undefined, lineStyle: lineTypes !== undefined || lineWeights !== undefined, textStyle: textStyles !== undefined, dimensionStyle: dimensionStyles !== undefined, } const openChange = (id: SWHeaderMenuId, callback?: (open: boolean) => void) => (next: boolean) => { if (next && panelOpen) setPanelOpen(false) setOpenMenu(current => (next ? id : current === id ? null : current)) callback?.(next) } const menuOpenProps = (id: SWHeaderMenuId, controlled: boolean | undefined, callback?: (open: boolean) => void) => ({ open: enabled(id) && (controlled ?? openMenu === id), onOpenChange: openChange(id, callback), }) const layerOpen = menuOpenProps("layer", layerMenuOpen, onLayerMenuOpenChange) const colorOpen = menuOpenProps("color", colorMenuOpen, onColorMenuOpenChange) const lineStyleOpen = menuOpenProps("lineStyle", lineStyleMenuOpen, onLineStyleMenuOpenChange) const textStyleOpen = menuOpenProps("textStyle", textStyleMenuOpen, onTextStyleMenuOpenChange) const dimensionStyleOpen = menuOpenProps("dimensionStyle", dimensionStyleMenuOpen, onDimensionStyleMenuOpenChange) const openers: Record void; onOpen?: () => void }> = { layer: { onOpenChange: layerOpen.onOpenChange, onOpen: onLayerOpen }, color: { onOpenChange: colorOpen.onOpenChange, onOpen: onColorOpen }, lineStyle: { onOpenChange: lineStyleOpen.onOpenChange, onOpen: onLineStyleOpen }, textStyle: { onOpenChange: textStyleOpen.onOpenChange, onOpen: onTextStyleOpen }, dimensionStyle: { onOpenChange: dimensionStyleOpen.onOpenChange, onOpen: onDimensionStyleOpen }, } /** * More → field: show the field in the representative slot (choosing the layer gives the slot back), * open its dropdown right below and move focus to the field. Calls the field's open callback once, * exactly like a click on the field; no data callback is called. */ const promote = (id: SWHeaderMenuId) => () => { setPromoted(id === baseMenu ? null : id) focusRef.current = id if (hasMenu[id]) openers[id].onOpenChange(true) openers[id].onOpen?.() } useEffect(() => { const id = focusRef.current if (!id) return focusRef.current = null // The slot is the field itself, or the wrapper of a field with a dropdown (layer: .hm-sw-header__layer). const slot = fieldsRef.current?.querySelector(scripted ? `[data-control="${id}"]` : id === "layer" ? ":scope > :is(.hm-sw-header__layer, [data-menu='layer'])" : `:scope > [data-menu="${menuSlot[id]}"]`) const field = slot?.matches(".hm-sw-header__field") ? slot : slot?.querySelector(".hm-sw-header__field") field?.focus() }) const currentLayerName = layers?.find(item => item.id === activeLayerId)?.name ?? layer.name const allMoreItems: SWHeaderMoreItem[] = [ { id: "layer", menu: "layer", label: "레이어", value: currentLayerName, tier: "slot", selected: activeMenu === "layer", disabled, onSelect: promote("layer") }, { id: "color", menu: "color", label: "색상", value: color.value, tier: "narrow", selected: activeMenu === "color", disabled, onSelect: promote("color") }, { id: "lineStyle", menu: "line-style", label: "선스타일", value: lineStyle.name, tier: "aux", selected: activeMenu === "lineStyle", disabled, onSelect: promote("lineStyle") }, { id: "textStyle", menu: "text-style", label: "문자스타일", value: textStyle.name, tier: "aux", selected: activeMenu === "textStyle", disabled, onSelect: promote("textStyle") }, { id: "dimensionStyle", menu: "dimension-style", label: "치수스타일", value: dimensionStyle.name, tier: "aux", selected: activeMenu === "dimensionStyle", disabled, onSelect: promote("dimensionStyle") }, { id: "propertyBar", label: "속성Bar", tier: "panel", checked: barOpen, disabled, onSelect: toggleBar }, { id: "propertyPanel", label: "속성창", tier: "panel", checked: panelOpen, disabled, onSelect: togglePanel }, { id: "print", label: "인쇄", tier: "action", disabled, onSelect: onPrint }, { id: "popupGuide", label: "팝업 가이드", tier: "action", disabled, guide: true, onSelect: onPopupGuide }, ] const moreItems = configuration.flatMap(config => { const item = allMoreItems.find(item => item.id === config.id) if (!item) return [] const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id) return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }] }).concat(openInHeader ? [{ id: "documentOpen", label: documentOpenLabel ?? "Open", tier: "action" as const, disabled, visible: openCollapsed, onSelect: onDocumentOpen }] : []) const fieldNodes: Record = { layer: (layers ? ( ) : ( )), color: (colors ? ( ) : ( )), lineStyle: (hasMenu.lineStyle ? ( ) : ( )), textStyle: (textStyles ? ( ) : ( )), dimensionStyle: (dimensionStyles ? ( ) : ( )), } const documentNode = ( { setDocumentOpen(next); if (next && homeOpen) setHomeOpen(false, "document") } : onDocumentMenuOpenChange} onDocumentSelect={onDocumentSelect} activeDocumentId={activeDocumentId} onDocumentClose={onDocumentClose} onNew={onDocumentNew} onOpen={onDocumentOpen} showNew={shown("documentNew")} showOpen={shown("documentOpen") && !openInHeader} documentIcon={documentIcon} newLabel={documentNewLabel} openLabel={documentOpenLabel} />) const configuredNodes: Partial> = { ...fieldNodes, document: documentNode, drawingMode: {label("drawingMode", drawingModeLabel)}, details: {label("details", "상세보기")}, propertyBar: , propertyPanel: , print: , popupGuide: , } const presence = (id: SWHeaderControlId, node: ReactNode) => presentRef.current.has(id)}>{node} const actionsShown = controls === undefined && (enabled("print") || enabled("popupGuide")) const windowShown = actionsShown || frameControls.slice(1).some(shown) const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide")))) // CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted. const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null) // The bar shares the panel data only when the panel has a selection (a content-only panel has no bar rows). const barData = propertyBar ?? (propertyPanel?.selection && { selection: propertyPanel.selection, properties: propertyPanel.properties ?? [], onPropertyChange: propertyPanel.onPropertyChange, }) return (
setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} /> {hasLeading &&
{openShown && {documentOpenLabel ?? "Open"}} {leading}
} {controls === undefined && enabled("document") && documentNode}
{controls === undefined && enabled("drawingMode") && !squeezedIds.has("drawingMode") && {drawingModeLabel}}
{controls ? configuration.map(config => { const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id) return
{presence(config.id, configuredNodes[config.id])}
}) : scripted ? menuIds.map(id => (
{presence(id, fieldNodes[id])}
)) : menuIds.map(id => {fieldNodes[id]})} {tools}
{moreItems.some(item => item.visible !== false) && 0} />} {controls === undefined && !squeezed.includes("properties") && }
{windowShown &&
{actionsShown && }
}
{barOpen && barData &&
barOffset ? { minWidth: barEnd - barOffset } : undefined} />
} {homeMenu && shown("home") && (
setHomeOpen(next, reason)} />
)} {panelOpen && propertyPanel && }
) })