"use client"; import { useLayoutEffect, useRef, useState } from 'react'; import { createOrgChart } from '@hm-design/org-chart'; import type { OrgChartAutoLayoutOptions, OrgChartDataSource, OrgChartHandle, OrgChartInteractionOptions, OrgChartOptions } from '@hm-design/org-chart'; import type { OrgChartEmployeeSelection, OrgChartProps } from './types.js'; function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable, customCallback: boolean) { const actionable = customCallback || popup !== 'none'; for (const card of shadow.querySelectorAll('.hm-orgchart-employee')) { const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? ''; const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? ''; // Auto cards also provide keyboard movement when the information popup is // disabled; popup ownership must not remove that Core keyboard entry point. card.tabIndex = 0; if (actionable) card.setAttribute('role', 'button'); else card.removeAttribute('role'); card.setAttribute('aria-label', `${name} ${position}${actionable ? ' 직원 정보 보기' : ''}`.trim()); if (actionable && !customCallback) card.setAttribute('aria-haspopup', 'dialog'); else card.removeAttribute('aria-haspopup'); } } // Core captures option objects by value. Compare them the same way so a parent // re-render with equal inline literals neither remounts nor re-fits the chart. const optionKey = (value: unknown) => JSON.stringify(value ?? null); interface Instance { readonly handle: OrgChartHandle; readonly generation: number; source: OrgChartDataSource; autoLayout: OrgChartAutoLayoutOptions | undefined; interactions: OrgChartInteractionOptions | undefined; selectedCompanyIds: readonly string[] | undefined; dragDropEnabled: boolean; readonly employeeClick: NonNullable; employeeCallbackEnabled: boolean; popupMode: NonNullable; operation: number; } /** A React host for the exact same Core used by Vanilla HTML. */ export function OrgChart({ dataSource, aggregationPolicy, stylesheetUrls, displayMode, layoutMode, autoLayout, interactions, dragDropEnabled = false, companyId, selectedCompanyIds, companyTopNodes, employeeConnections, zoom, presentationMode, initialView, wheelZoom, organizationLinks, onEmployeeClick, onMove, employeePopup = 'core', employeePopupAdapter: EmployeePopupAdapter, className, style, onStateChange, onLifecycleError, }: OrgChartProps) { const containerRef = useRef(null); const instanceRef = useRef(null); const generationRef = useRef(0); const [employeeSelection, setEmployeeSelection] = useState(null); const requestedAdapter = employeePopup === 'hm-dialog' || employeePopup === 'adapter'; const effectivePopup = requestedAdapter && !EmployeePopupAdapter ? 'core' : employeePopup; const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup: effectivePopup }); // Publish committed values before Core effects run, without letting callback // identity changes or abandoned concurrent renders alter Core ownership. useLayoutEffect(() => { committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup: effectivePopup }; }, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, effectivePopup]); useLayoutEffect(() => { if (layoutMode !== 'auto' || !requestedAdapter || EmployeePopupAdapter || onEmployeeClick) return; try { committedRef.current.onLifecycleError?.(new Error('OrgChart popup adapter is missing. Use @hm-design/org-chart-react/hm-dialog or supply employeePopupAdapter. Falling back to the Core popup.')); } catch { /* Consumer error reporting cannot prevent Core rendering. */ } }, [layoutMode, requestedAdapter, EmployeePopupAdapter, Boolean(onEmployeeClick)]); const engineStylesheetUrl = stylesheetUrls.engine; const coreStylesheetUrl = stylesheetUrls.core; const hasEmployeeCallback = typeof onEmployeeClick === 'function'; const popupMode = layoutMode === 'auto' ? effectivePopup : 'core'; const adapterOwnsPopup = popupMode !== 'core'; const hasAdapterCallback = hasEmployeeCallback || popupMode !== 'core'; const fixedHasEmployeeCallback = layoutMode !== 'auto' && hasEmployeeCallback; // Ownership changes are synchronous with the commit: an old request cannot // notify between a committed unmount/source change and a passive cleanup. useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const generation = ++generationRef.current; let handle: OrgChartHandle | undefined; let unsubscribe: (() => void) | undefined; let popupObserver: MutationObserver | undefined; const reportError = (error: unknown) => { if (generationRef.current !== generation) return; try { committedRef.current.onLifecycleError?.(error); } catch { /* Consumer errors must not leave rejected lifecycle promises. */ } }; try { const source = committedRef.current.dataSource; const employeeClick: NonNullable = (employee, context) => { if (generationRef.current !== generation) return; const callback = committedRef.current.onEmployeeClick; if (callback) callback(employee, context); else if (committedRef.current.employeePopup === 'hm-dialog' || committedRef.current.employeePopup === 'adapter') setEmployeeSelection({ employee, context }); }; handle = createOrgChart({ dataSource: source, aggregationPolicy, stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl }, displayMode, layoutMode, autoLayout, interactions, dragDropEnabled, companyId, selectedCompanyIds, companyTopNodes, employeeConnections, zoom, presentationMode, initialView, wheelZoom, organizationLinks, onEmployeeClick: hasAdapterCallback ? employeeClick : undefined, onMove: result => { if (generationRef.current !== generation) return; if (result.moved) setEmployeeSelection(null); committedRef.current.onMove?.(result); }, }); const instance: Instance = { handle, source, autoLayout, interactions, selectedCompanyIds, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 }; instanceRef.current = instance; unsubscribe = handle.subscribe(state => { if (instanceRef.current === instance && generationRef.current === generation) { committedRef.current.onStateChange?.(state); } }); // Core mount performs the first load. The source effect below compares // identity against this record and does not load the initial source again. const operation = instance.operation; void handle.mount(container).catch(error => { if (instanceRef.current === instance && instance.operation === operation) reportError(error); }); const shadow = container.firstElementChild?.shadowRoot; if (shadow && layoutMode === 'auto') { const annotateCards = () => { annotateEmployeeCards(shadow, committedRef.current.employeePopup, Boolean(committedRef.current.onEmployeeClick)); }; const Observer = container.ownerDocument.defaultView?.MutationObserver; if (Observer) { popupObserver = new Observer(annotateCards); popupObserver.observe(shadow, { childList: true, subtree: true }); } annotateCards(); } } catch (error) { reportError(error); } return () => { // Invalidate callbacks and promise continuations before terminal disposal. if (generationRef.current === generation) ++generationRef.current; if (instanceRef.current?.generation === generation) instanceRef.current = null; popupObserver?.disconnect(); try { unsubscribe?.(); } catch (error) { try { committedRef.current.onLifecycleError?.(error); } catch { /* A consumer callback cannot prevent terminal Core disposal. */ } } finally { try { handle?.destroy(); } catch (error) { try { committedRef.current.onLifecycleError?.(error); } catch { /* Cleanup still completes when the consumer callback throws. */ } } } }; }, [optionKey(aggregationPolicy), engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, optionKey(companyTopNodes), optionKey(employeeConnections), optionKey(zoom), presentationMode, initialView, wheelZoom, optionKey(organizationLinks), fixedHasEmployeeCallback]); useLayoutEffect(() => { const instance = instanceRef.current; if (!instance || layoutMode !== 'auto' || (instance.employeeCallbackEnabled === hasAdapterCallback && instance.popupMode === popupMode)) return; try { instance.handle.setEmployeeClickHandler(hasAdapterCallback ? instance.employeeClick : undefined); instance.employeeCallbackEnabled = hasAdapterCallback; instance.popupMode = popupMode; } catch (error) { try { committedRef.current.onLifecycleError?.(error); } catch { /* Match Core's callback ownership isolation. */ } } }, [hasAdapterCallback, popupMode, layoutMode]); useLayoutEffect(() => { // Close a host-owned popup before committing a changed source/company view; // no asynchronous fixture request can leave a stale employee displayed. setEmployeeSelection(null); const shadow = containerRef.current?.firstElementChild?.shadowRoot; if (shadow && layoutMode === 'auto') { annotateEmployeeCards(shadow, popupMode, hasEmployeeCallback); } }, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup, EmployeePopupAdapter]); useLayoutEffect(() => { const instance = instanceRef.current; if (!instance || layoutMode !== 'auto' || optionKey(instance.autoLayout) === optionKey(autoLayout)) return; instance.autoLayout = autoLayout; void instance.handle.setAutoLayout(autoLayout ?? { type: 'tb', hybridStartDepth: 3, initialVisibleDepth: 'all', hybridOrganizationIds: [] }).catch(error => { if (instanceRef.current !== instance) return; try { committedRef.current.onLifecycleError?.(error); } catch { /* Keep layout setter failures in the lifecycle error contract. */ } }); }, [optionKey(autoLayout), layoutMode]); useLayoutEffect(() => { const instance = instanceRef.current; if (!instance || layoutMode !== 'auto' || instance.dragDropEnabled === dragDropEnabled) return; try { instance.handle.setDragDropEnabled(dragDropEnabled); instance.dragDropEnabled = dragDropEnabled; } catch (error) { try { committedRef.current.onLifecycleError?.(error); } catch { /* Keep option changes in the lifecycle error contract. */ } } }, [dragDropEnabled, layoutMode]); useLayoutEffect(() => { const instance = instanceRef.current; if (!instance || layoutMode !== 'auto' || optionKey(instance.interactions) === optionKey(interactions)) return; instance.interactions = interactions; void instance.handle.setInteractions({ collapse: true, zoom: true, pan: true, ...interactions }).catch(error => { if (instanceRef.current !== instance) return; try { committedRef.current.onLifecycleError?.(error); } catch { /* Interaction controls use the existing lifecycle error contract. */ } }); }, [optionKey(interactions), layoutMode]); useLayoutEffect(() => { const instance = instanceRef.current; // Mount already passed this selection to Core; only a changed value re-applies. if (!instance || layoutMode !== 'auto' || optionKey(instance.selectedCompanyIds) === optionKey(selectedCompanyIds)) return; instance.selectedCompanyIds = selectedCompanyIds; void instance.handle.setSelectedCompanies(selectedCompanyIds).catch(error => { if (instanceRef.current !== instance) return; try { committedRef.current.onLifecycleError?.(error); } catch { /* Match other lifecycle callbacks: never leak a rejected Promise. */ } }); }, [optionKey(selectedCompanyIds), layoutMode]); useLayoutEffect(() => { const instance = instanceRef.current; if (!instance || instance.source === dataSource) return; instance.source = dataSource; const operation = ++instance.operation; // Core reload synchronously starts a new revision, aborting/ignoring earlier // source requests even when their Promise or engine import is still pending. void instance.handle.reload(dataSource).catch(error => { if (instanceRef.current !== instance || generationRef.current !== instance.generation || instance.operation !== operation) return; try { committedRef.current.onLifecycleError?.(error); } catch { /* Consumer errors must not become unhandled Promise rejections. */ } }); }, [dataSource]); return <>
{(popupMode === 'hm-dialog' || popupMode === 'adapter') && EmployeePopupAdapter && !hasEmployeeCallback ? setEmployeeSelection(null)} /> : null} ; }