import type OrgChart from 'orgchart'; import type { EngineNode, EngineOptions } from 'orgchart'; export type RenderEngineNode = EngineNode; export interface EngineEditing { canDrop(source: HTMLElement, target: HTMLElement): boolean; onDrop(source: HTMLElement, target: HTMLElement): void; } export interface EngineAdapter { update(data: RenderEngineNode[]): void; toggle(node: HTMLElement): boolean | undefined; prepareDrag(source: HTMLElement): void; drop(source: HTMLElement, target: HTMLElement): void; cancelDrag(): void; dispose(): void; } /** Importing the public facade never evaluates the upstream browser-only module. */ export async function loadEngine() { const { default: Upstream } = await import('orgchart'); // 6.0.0 has no destroy(). Its init observer is local to triggerInitEvent. // Override that narrow hook on our subclass to keep the resource disposable. return class ManagedOrgChart extends Upstream { declare initObserver?: MutationObserver; override init(options?: Parameters[0]): OrgChart { try { return super.init(options); } catch (error) { this.disconnectInitObserver(); this.chart?.remove(); this.chart = null; throw error; } } override triggerInitEvent(): void { this.disconnectInitObserver(); const Observer = this.chartContainer?.ownerDocument.defaultView?.MutationObserver; if (!Observer || !this.chartContainer) return; const observer = new Observer(() => { observer.disconnect(); if (this.initObserver === observer) this.initObserver = undefined; if (this.chart) this.handleCompactNodes(); }); this.initObserver = observer; observer.observe(this.chartContainer, { childList: true }); } disconnectInitObserver(): void { this.initObserver?.disconnect(); this.initObserver = undefined; } }; } export type EngineConstructor = Awaited>; export function createEngineAdapter( Engine: EngineConstructor, viewport: HTMLElement, data: RenderEngineNode[], decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void, layoutOptions: Pick = {}, editing?: EngineEditing, ): EngineAdapter { const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController; let generation = new Abort(); let instances: (OrgChart & { disconnectInitObserver(): void })[] = []; let disposed = false; const forest = viewport.ownerDocument.createElement('div'); forest.className = 'hm-orgchart-forest'; viewport.append(forest); function cancelDrag(): void { if (!editing) return; for (const instance of instances) instance.dragendHandler(); } function releaseGeneration(): void { cancelDrag(); generation.abort(); for (const instance of instances) { instance.disconnectInitObserver(); // Keep the old chart reference until upstream feature cleanup is complete. instance.unbindPan(); instance.unbindZoom(); instance.detachMinimap(); } } function disposeInstances(): void { releaseGeneration(); for (const instance of instances) { instance.chart?.remove(); instance.chart = null; instance.chartContainer = null; instance.opts = {}; instance.options = {}; } instances = []; forest.replaceChildren(); } function build(roots: RenderEngineNode[]): void { try { for (const root of roots) { const treeContainer = viewport.ownerDocument.createElement('div'); treeContainer.className = 'hm-orgchart-tree'; forest.append(treeContainer); const instance = new Engine(treeContainer, { ...layoutOptions, data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false, chartClass: 'hm-orgchart-engine', ...(editing ? { dropCriteria: (source: HTMLElement, _parent: HTMLElement | null, target: HTMLElement) => editing.canDrop(source, target) } : {}), nodeTemplate: () => '
', createNode: (node, value) => decorate(node, value, generation.signal), }); instances.push(instance); } } catch (error) { disposeInstances(); throw error; } } function syncButtons(): void { for (const instance of instances) { for (const node of instance.chart?.querySelectorAll('.node') ?? []) { const button = node.querySelector('.hm-orgchart-toggle'); if (!button) continue; const expanded = instance.getNodeState(node, 'children').visible && !node.closest('.hierarchy')?.classList.contains('isChildrenCollapsed'); button.setAttribute('aria-expanded', String(expanded)); const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기'; button.textContent = label; button.setAttribute('aria-label', `${node.querySelector('.hm-orgchart-organization-name')?.textContent ?? ''} ${label}`); } } } function bindGeneration(): void { forest.addEventListener('transitionend', syncButtons, { signal: generation.signal }); if (!editing) return; for (const instance of instances) { // Core owns immutable data and rerendering. Reuse upstream drop events, // but cancel its DOM/data mutation before either horizontal or hybrid path. const commitDrop = (event: Event) => { event.preventDefault(); const detail = (event as CustomEvent<{ draggedNode?: HTMLElement; draggedItem?: HTMLElement; dropZone: HTMLElement }>).detail; if (!detail) return; const source = detail.draggedNode ?? detail.draggedItem; const target = detail.dropZone; if (source && target && instance.chart?.contains(source) && instances.some(candidate => candidate.chart?.contains(target)) && editing.canDrop(source, target)) { editing.onDrop(source, target); } }; instance.chart?.addEventListener('nodedrop.orgchart', commitDrop, { signal: generation.signal }); instance.chart?.addEventListener('otherdropped.orgchart', commitDrop, { signal: generation.signal }); } } build(data); bindGeneration(); return { update(next) { if (disposed) throw new Error('OrgChart engine is disposed'); releaseGeneration(); generation = new Abort(); if (next.length === instances.length) { for (let index = 0; index < next.length; index++) instances[index].init({ data: next[index] }); } else { disposeInstances(); generation = new Abort(); build(next); } bindGeneration(); }, toggle(node) { const instance = instances.find(candidate => candidate.chart?.contains(node)); if (!instance || node.closest('.hierarchy')?.querySelector('.sliding')) return undefined; const expanded = instance.getNodeState(node, 'children').visible; if (expanded) instance.hideChildren(node); else instance.showChildren(node); syncButtons(); return !expanded; }, prepareDrag(source) { if (disposed || !editing) return; cancelDrag(); if (!instances.some(candidate => candidate.chart?.contains(source))) return; // Independent company trees share one editing policy. Upstream caches the // source and filters targets within its own chart, so filter each tree. for (const instance of instances) instance.filterAllowedDropNodes(source); }, drop(source, target) { if (disposed || !editing) return; const instance = instances.find(candidate => candidate.chart?.contains(source)); if (!instance || !instances.some(candidate => candidate.chart?.contains(target)) || !editing.canDrop(source, target)) { cancelDrag(); return; } // Refresh upstream's cached source and allowed targets for the final data // policy check. Pointer coordinates and thresholds stay in the Core UI. for (const candidate of instances) candidate.filterAllowedDropNodes(source); void instance.dropHandler({ dropZone: target }); cancelDrag(); }, cancelDrag, dispose() { if (disposed) return; disposed = true; disposeInstances(); viewport.replaceChildren(); }, }; }