feat(org-chart): finalize chart features and AI generation rules
This commit is contained in:
1 parent
7614507583
commit
d0828a7ff4
80 files changed
+8152
-221
No files matched your search
@@ -0,0 +1,161 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user