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

162 lines
9.2 KiB
TypeScript

import type { OrgChartMoveRequest, OrgChartMoveValidation } from '../data/index.js';
import type { EngineAdapter } from './engine.js';
export interface ExpandedEditing {
setEnabled(enabled: boolean): void;
}
/** The pointer threshold is shared conceptually with Pan (6 screen pixels).
* Upstream owns allowedDrop and drop events; Core owns immutable relationships.
* Browser hit testing already includes the forest's Zoom/Pan transform. */
export function createExpandedEditing(viewport: HTMLElement, feedback: HTMLElement, signal: AbortSignal,
engine: EngineAdapter, validate: (request: OrgChartMoveRequest) => OrgChartMoveValidation,
move: (request: OrgChartMoveRequest) => void, suppressClick: () => void, initiallyEnabled = false): ExpandedEditing {
const document = viewport.ownerDocument, view = document.defaultView!;
let enabled = initiallyEnabled;
const nodes = () => [...viewport.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
let pending: { source: HTMLElement; id: number; x: number; y: number; moved: boolean } | undefined;
let keyboardSource: HTMLElement | undefined;
let hover: HTMLElement | undefined;
const sourceFor = (target: EventTarget | null): HTMLElement | undefined => {
if (!(target instanceof view.Element)) return;
const employee = target.closest<HTMLElement>('.hm-orgchart-employee');
if (employee && viewport.contains(employee)) return employee;
const title = target.closest('.hm-orgchart-expanded-toggle');
if (title && viewport.contains(title)) return title.closest<HTMLElement>('.node') ?? undefined;
};
const requestFor = (source: HTMLElement, target?: HTMLElement): OrgChartMoveRequest => ({
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!,
targetOrganizationId: target?.dataset.organizationId ?? '',
});
const name = (node: HTMLElement) => node.querySelector('.hm-orgchart-expanded-toggle')?.textContent ?? node.dataset.organizationId!;
function clear(): void {
engine.cancelDrag();
for (const element of viewport.querySelectorAll<HTMLElement>('[data-drop-state], [data-drop-hover], [data-edit-source]')) {
delete element.dataset.dropState; delete element.dataset.dropHover; delete element.dataset.editSource;
}
hover = undefined; delete viewport.dataset.editDragging;
}
function start(source: HTMLElement): void {
clear(); engine.prepareDrag(source);
source.dataset.editSource = 'true'; viewport.dataset.editDragging = 'true';
for (const target of nodes()) target.dataset.dropState = validate(requestFor(source, target)).allowed ? 'allowed' : 'blocked';
feedback.dataset.moveStatus = 'dragging';
feedback.textContent = '이동할 조직에 놓으세요. 회사 간 이동도 가능합니다. 조직장·최상위 조직은 이동할 수 없습니다.';
}
function selectTarget(source: HTMLElement, target?: HTMLElement): void {
if (hover) delete hover.dataset.dropHover;
hover = target;
if (!target) return;
const policy = validate(requestFor(source, target));
target.dataset.dropHover = policy.allowed ? 'allowed' : 'blocked';
feedback.textContent = policy.allowed ? `${name(target)} · 이동 가능` : `${name(target)} · ${policy.message}`;
}
const hitTarget = (x: number, y: number) => {
const box = viewport.getBoundingClientRect();
if (x < box.left || x >= box.right || y < box.top || y >= box.bottom) return;
// The presenter binds before its fragment is attached to the host Shadow DOM.
// Resolve the live root at hit-test time instead of retaining that fragment.
const root = viewport.getRootNode();
const hit = (root instanceof view.ShadowRoot ? root : document).elementFromPoint(x, y);
const node = hit?.closest<HTMLElement>('.node[data-organization-id]');
return node && viewport.contains(node) ? node : undefined;
};
function drop(source: HTMLElement, target?: HTMLElement): boolean {
if (!enabled) { clear(); return false; }
const request = requestFor(source, target), policy = validate(request);
// The upstream otherdropped event intentionally skips its allowedDrop check.
// Always revalidate before entering either upstream drop-event route.
if (target && policy.allowed) engine.drop(source, target);
else move(request);
clear();
return policy.allowed && !!target;
}
viewport.addEventListener('dragstart', event => event.preventDefault(), { capture: true, signal });
viewport.addEventListener('pointerdown', event => {
if (!enabled || event.button !== 0 || !event.isPrimary || keyboardSource) return;
const source = sourceFor(event.target);
if (!source) return;
pending = { source, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
}, { signal });
view.addEventListener('pointermove', event => {
if (!enabled || !pending || pending.id !== event.pointerId) return;
if (!pending.moved && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) < 6) return;
if (!pending.moved) {
pending.moved = true; viewport.setPointerCapture(event.pointerId); start(pending.source);
}
event.preventDefault();
selectTarget(pending.source, hitTarget(event.clientX, event.clientY));
}, { passive: false, signal });
function finish(event: PointerEvent): void {
if (!pending || pending.id !== event.pointerId) return;
const drag = pending; pending = undefined;
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
if (!drag.moved) return;
suppressClick();
if (event.type === 'pointerup') drop(drag.source, hitTarget(event.clientX, event.clientY));
else { clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.'; }
}
view.addEventListener('pointerup', finish, { signal });
view.addEventListener('pointercancel', finish, { signal });
viewport.addEventListener('lostpointercapture', event => {
// Touch initially captures its card. Transferring capture to the viewport
// releases that descendant and bubbles here; the viewport still owns Drag.
if (event.target !== viewport || !pending?.moved) return;
pending = undefined; clear(); feedback.textContent = '이동을 취소했습니다.';
}, { signal });
const cancel = (restoreFocus = true) => {
const source = keyboardSource;
if (pending?.moved) suppressClick();
const id = pending?.id; pending = undefined; keyboardSource = undefined;
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.';
const focus = source?.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
if (restoreFocus) focus?.focus({ preventScroll: true });
};
view.addEventListener('blur', () => { if (pending || keyboardSource) cancel(); }, { signal });
viewport.addEventListener('keydown', event => {
if (!enabled) return;
if (event.key === 'Escape' && (keyboardSource || pending)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); return; }
if (!keyboardSource && event.altKey && event.key.toLowerCase() === 'm') {
const source = sourceFor(event.target);
if (!source) return;
event.preventDefault(); event.stopImmediatePropagation(); keyboardSource = source; start(source);
const targets = nodes(), target = targets.find(target => validate(requestFor(source, target)).allowed) ?? targets[0];
selectTarget(source, target);
if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); }
return;
}
if (!keyboardSource) return;
if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
event.preventDefault(); event.stopImmediatePropagation();
const targets = nodes(), index = Math.max(0, targets.indexOf(hover!));
const next = event.key === 'Home' ? 0 : event.key === 'End' ? targets.length - 1
: (index + (['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1) + targets.length) % targets.length;
selectTarget(keyboardSource, targets[next]);
if (hover) { hover.tabIndex = -1; hover.focus({ preventScroll: true }); }
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.stopImmediatePropagation(); const source = keyboardSource; keyboardSource = undefined;
if (!drop(source, hover)) {
const focus = source.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
focus?.focus({ preventScroll: true });
}
} else if (event.key === 'Tab') { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }
}, { capture: true, signal });
signal.addEventListener('abort', () => {
const id = pending?.id; pending = undefined; keyboardSource = undefined;
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
clear();
}, { once: true });
return {
setEnabled(next) {
if (enabled === next) return;
enabled = next;
if (!enabled) {
if (pending || keyboardSource) cancel(false);
else clear();
}
},
};
}