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('.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('.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('.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('[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('.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('.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('.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(); } }, }; }