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