Files
HMDesign/packages/org-chart/src/core/engine.ts
T

196 lines
8.2 KiB
TypeScript

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<OrgChart['init']>[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<ReturnType<typeof loadEngine>>;
export function createEngineAdapter(
Engine: EngineConstructor,
viewport: HTMLElement,
data: RenderEngineNode[],
decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void,
layoutOptions: Pick<EngineOptions, 'direction' | 'verticalLevel' | 'visibleLevel'> = {},
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: () => '<article class="hm-orgchart-organization"></article>',
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<HTMLElement>('.node') ?? []) {
const button = node.querySelector<HTMLButtonElement>('.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();
},
};
}