756 lines
46 KiB
TypeScript
756 lines
46 KiB
TypeScript
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<SWHeaderMenuId, string> = {
|
|
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<Record<SWHeaderControlId, number>> = {
|
|
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<HTMLElement, SWHeaderProps>(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<string>(hiddenControls ?? [])
|
|
const shown = (id: SWHeaderHideableControlId) => !removed.has(id)
|
|
const seen = new Set<SWHeaderControlId>()
|
|
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<readonly string[]>([])
|
|
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<SWHeaderMenuId | null>(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<SWHeaderMenuId | null>(null)
|
|
const focusRef = useRef<SWHeaderMenuId | null>(null)
|
|
const fieldsRef = useRef<HTMLDivElement>(null)
|
|
const panelAnchorRef = useRef<HTMLDivElement>(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<HTMLButtonElement>('[data-property-panel-trigger]')
|
|
const more = header?.querySelector<HTMLButtonElement>('.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<number | null>(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<HTMLDivElement>(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<HTMLElement>(".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<HTMLElement>(".hm-sw-header")
|
|
const area = panelAnchorRef.current
|
|
const fields = fieldsRef.current
|
|
const documentBox = header?.querySelector<HTMLElement>(":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<HTMLElement | null>(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<HTMLElement>(".hm-sw-header")
|
|
homeButtonRef.current = header?.querySelector<HTMLElement>(":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<HTMLElement>(':scope > [data-control="document"]:not([data-hidden])')?.getBoundingClientRect().right
|
|
const start = Math.max(panelAnchorRef.current.getBoundingClientRect().left, documentRight ?? -Infinity)
|
|
const end = header.querySelector<HTMLElement>(":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<string, { before: number; room?: number }>())
|
|
// 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<number | null>(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<string> }>({ width: 0, keys: new Set() })
|
|
useLayoutEffect(() => {
|
|
const header = fieldsRef.current?.closest<HTMLElement>(".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<HTMLElement>("[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<HTMLElement>(":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<SWHeaderMenuId, boolean> = {
|
|
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<SWHeaderMenuId, { onOpenChange: (open: boolean) => 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<HTMLElement>(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<HTMLElement>(".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<SWHeaderMenuId, ReactNode> = {
|
|
layer: (layers ? (
|
|
<SWHeaderLayerMenu label={label("layer", "레이어")}
|
|
value={layer}
|
|
layers={layers}
|
|
activeLayerId={activeLayerId}
|
|
{...layerOpen}
|
|
onClick={onLayerOpen}
|
|
selected={activeMenu === "layer"}
|
|
disabled={disabled}
|
|
onLayerSelect={onLayerSelect}
|
|
onVisibilityChange={onLayerVisibilityChange}
|
|
onFreezeChange={onLayerFreezeChange}
|
|
onLockChange={onLayerLockChange}
|
|
onDetail={onLayerDetail}
|
|
{...layerStates}
|
|
/>
|
|
) : (
|
|
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} {...layerStates} />
|
|
)),
|
|
color: (colors ? (
|
|
<SWHeaderColorMenu label={label("color", "색상")}
|
|
value={color}
|
|
colors={colors}
|
|
userColors={userColors}
|
|
mode={colorMode}
|
|
{...colorOpen}
|
|
onClick={onColorOpen}
|
|
selected={activeMenu === "color"}
|
|
disabled={disabled}
|
|
onColorSelect={onColorSelect}
|
|
onModeChange={onColorModeChange}
|
|
onColorInput={onColorInput}
|
|
onPick={onColorPick}
|
|
onDetail={onColorDetail}
|
|
/>
|
|
) : (
|
|
<SWHeaderColorControl label={label("color", "색상")} value={color} selected={activeMenu === "color"} disabled={disabled} onClick={onColorOpen} />
|
|
)),
|
|
lineStyle: (hasMenu.lineStyle ? (
|
|
<SWHeaderLineStyleMenu label={label("lineStyle", "선스타일")}
|
|
value={lineStyle}
|
|
lineTypes={lineTypes}
|
|
lineWeights={lineWeights}
|
|
activeLineTypeId={activeLineTypeId}
|
|
activeLineWeightId={activeLineWeightId}
|
|
{...lineStyleOpen}
|
|
onClick={onLineStyleOpen}
|
|
selected={activeMenu === "lineStyle"}
|
|
disabled={disabled}
|
|
onLineTypeSelect={onLineTypeSelect}
|
|
onLineWeightSelect={onLineWeightSelect}
|
|
onLineWeightValueChange={onLineWeightValueChange}
|
|
onScaleChange={onLineScaleChange}
|
|
onDetail={onLineStyleDetail}
|
|
/>
|
|
) : (
|
|
<SWHeaderLineStyle label={label("lineStyle", "선스타일")} value={lineStyle} selected={activeMenu === "lineStyle"} disabled={disabled} onClick={onLineStyleOpen} />
|
|
)),
|
|
textStyle: (textStyles ? (
|
|
<SWHeaderTextStyleMenu label={label("textStyle", "문자스타일")}
|
|
value={textStyle}
|
|
textStyles={textStyles}
|
|
activeTextStyleId={activeTextStyleId}
|
|
{...textStyleOpen}
|
|
onClick={onTextStyleOpen}
|
|
selected={activeMenu === "textStyle"}
|
|
disabled={disabled}
|
|
onTextStyleSelect={onTextStyleSelect}
|
|
onDetail={onTextStyleDetail}
|
|
/>
|
|
) : (
|
|
<SWHeaderTextStyle label={label("textStyle", "문자스타일")} value={textStyle} selected={activeMenu === "textStyle"} disabled={disabled} onClick={onTextStyleOpen} />
|
|
)),
|
|
dimensionStyle: (dimensionStyles ? (
|
|
<SWHeaderDimensionStyleMenu label={label("dimensionStyle", "치수스타일")}
|
|
value={dimensionStyle}
|
|
dimensionStyles={dimensionStyles}
|
|
activeDimensionStyleId={activeDimensionStyleId}
|
|
{...dimensionStyleOpen}
|
|
onClick={onDimensionStyleOpen}
|
|
selected={activeMenu === "dimensionStyle"}
|
|
disabled={disabled}
|
|
onDimensionStyleSelect={onDimensionStyleSelect}
|
|
onDetail={onDimensionStyleDetail}
|
|
/>
|
|
) : (
|
|
<SWHeaderDimensionStyle label={label("dimensionStyle", "치수스타일")} value={dimensionStyle} selected={activeMenu === "dimensionStyle"} disabled={disabled} onClick={onDimensionStyleOpen} />
|
|
)),
|
|
}
|
|
const documentNode = (<SWHeaderDocumentTitle
|
|
label={label("document", "문서")}
|
|
title={documentTitle}
|
|
onClick={onDocumentClick}
|
|
documents={documents}
|
|
// With a logo menu SWHeader holds the document menu state, so the two never stay open together.
|
|
menuOpen={homeMenu ? documentOpenNow : documentMenuOpen}
|
|
onMenuOpenChange={homeMenu ? next => { 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<Record<SWHeaderControlId, ReactNode>> = {
|
|
...fieldNodes,
|
|
document: documentNode,
|
|
drawingMode: <span className="hm-sw-header__mode">{label("drawingMode", drawingModeLabel)}</span>,
|
|
details: <span className="hm-sw-header__properties-caption">{label("details", "상세보기")}</span>,
|
|
propertyBar: <button type="button" className="hm-sw-header__toggle" aria-pressed={barOpen} aria-controls={barOpen ? barId : undefined} disabled={disabled} onClick={toggleBar}>{label("propertyBar", "속성Bar")}</button>,
|
|
propertyPanel: <button type="button" className="hm-sw-header__toggle" data-property-panel-trigger="" aria-pressed={panelOpen} aria-haspopup={propertyPanel ? "dialog" : undefined} aria-expanded={panelOpen} aria-controls={panelOpen ? panelId : undefined} disabled={disabled} onClick={togglePanel}>{label("propertyPanel", "속성창")}</button>,
|
|
print: <button type="button" className="hm-sw-header__action hm-sw-header__action--print" aria-label={label("print", "인쇄")} disabled={disabled} onClick={onPrint}><img className="hm-sw-header__icon" src={printIcon} alt="" /><img className="hm-sw-header__icon-hover" src={printHover} alt="" /></button>,
|
|
popupGuide: <SWHeaderKGuide label={label("popupGuide", "팝업 가이드")} disabled={disabled} onClick={onPopupGuide} />,
|
|
}
|
|
const presence = (id: SWHeaderControlId, node: ReactNode) => <SWHeaderCommitPresence.Provider value={() => presentRef.current.has(id)}>{node}</SWHeaderCommitPresence.Provider>
|
|
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 (
|
|
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} data-home-menu-open={homeOpen || undefined} data-actions-held={squeezed.includes("actions") || undefined} {...rest}>
|
|
<div className="hm-sw-header__bar">
|
|
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => 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 && <div ref={leadingRef} className="hm-sw-header__leading">
|
|
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
|
|
{leading}
|
|
</div>}
|
|
{controls === undefined && enabled("document") && documentNode}
|
|
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined} data-squeezed={squeezed.length ? squeezed.join(" ") : undefined}>
|
|
{controls === undefined && enabled("drawingMode") && !squeezedIds.has("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
|
|
<div ref={fieldsRef} className="hm-sw-header__fields" data-configured={controls !== undefined || undefined}>
|
|
{controls ? configuration.map(config => {
|
|
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
|
|
return <div key={config.id} className="hm-sw-header__control" data-control={config.id} data-hidden={hidden || undefined}>{presence(config.id, configuredNodes[config.id])}</div>
|
|
}) : scripted ? menuIds.map(id => (
|
|
<div key={id} className="hm-sw-header__control" data-control={id} data-hidden={fieldHidden(id) || undefined} data-representative={(id === representative && id !== baseMenu) || undefined}>{presence(id, fieldNodes[id])}</div>
|
|
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
|
|
{tools}
|
|
</div>
|
|
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed || squeezed.length > 0} />}
|
|
{controls === undefined && !squeezed.includes("properties") && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
|
|
</div>
|
|
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
|
|
{actionsShown && <SWHeaderActions disabled={disabled} onPrint={onPrint} onPopupGuide={onPopupGuide} showPrint={enabled("print")} showPopupGuide={enabled("popupGuide")} />}
|
|
<SWHeaderWindowControls onWindowMoveStart={onWindowMoveStart} onMinimize={onMinimize} onClose={onClose} showWindowMove={shown("windowMove")} showMinimize={shown("minimize")} showClose={shown("close")} />
|
|
</div>}
|
|
</div>
|
|
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} style={barEnd !== null && barEnd > barOffset ? { minWidth: barEnd - barOffset } : undefined} /></div>}
|
|
{homeMenu && shown("home") && (
|
|
<div className="hm-sw-header__home-menu" style={{ left: homeBox.left, maxWidth: homeBox.width }}>
|
|
<SWHomeMenu {...homeMenu} id={homeMenu.id ?? homeMenuId} open={homeOpen} returnFocusRef={homeButtonRef}
|
|
onOpenChange={(next, reason) => setHomeOpen(next, reason)} />
|
|
</div>
|
|
)}
|
|
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
|
|
</header>
|
|
)
|
|
})
|