Files
HMDesign/packages/org-chart-react/src/OrgChart.tsx
T
shkim4 d56d587fd2 feat(orgchart): integrate chart-only viewport and scoped guard fixes
Add initial fit and direct wheel zoom with preserved default presentation. Integrate five scoped GuardFix findings and AI generation preselection policy. Exclude Cell experiments and retain five historical guard failures.
2026-10-13 18:41:14 +09:00

286 lines
13 KiB
TypeScript

"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<OrgChartProps['employeePopup']>, customCallback: boolean) {
const actionable = customCallback || popup !== 'none';
for (const card of shadow.querySelectorAll<HTMLElement>('.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<OrgChartOptions['onEmployeeClick']>;
employeeCallbackEnabled: boolean;
popupMode: NonNullable<OrgChartProps['employeePopup']>;
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<HTMLDivElement>(null);
const instanceRef = useRef<Instance | null>(null);
const generationRef = useRef(0);
const [employeeSelection, setEmployeeSelection] = useState<OrgChartEmployeeSelection | null>(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<OrgChartOptions['onEmployeeClick']> = (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 <>
<div ref={containerRef} className={className} style={style} />
{(popupMode === 'hm-dialog' || popupMode === 'adapter') && EmployeePopupAdapter && !hasEmployeeCallback
? <EmployeePopupAdapter selection={employeeSelection} onClose={() => setEmployeeSelection(null)} /> : null}
</>;
}