196 lines
8.2 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|