intra_monitor/public/js/keyboard.js
2026-03-18 14:19:29 +03:00

305 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════════════
// keyboard.js — Централизованный менеджер клавиатурных шорткатов
// Подключить в index.html ПОСЛЕ всех остальных скриптов:
// <script src="/js/keyboard.js"></script>
// ═══════════════════════════════════════════════════════════════════
// ── Константы навигации ────────────────────────────────────────────
const KB_PAGE_SIZE = 10; // строк на PageUp/PageDown
// ── Индекс «активной» строки (курсор списка, отдельно от открытого меню) ──
// activeRowIdx — выделенная строка стрелками; currentModalIdx — открытое боковое меню.
// Они могут совпадать или расходиться: курсор остаётся на месте при закрытии меню.
let activeRowIdx = null; // null = курсор не установлен
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 1: Проверка «безопасности» фокуса
// Цель: не перехватывать клавиши, когда юзер что-то вводит в поля.
// ═══════════════════════════════════════════════════════════════════
function kbIsInputFocused() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
// Любой редактируемый элемент → шорткаты отключены
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (el.isContentEditable) return true;
// ace/codemirror или любой кастомный редактор с role="textbox"
if (el.getAttribute('role') === 'textbox') return true;
return false;
}
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 2: Навигация по списку
// ═══════════════════════════════════════════════════════════════════
/** Возвращает число строк в текущем отфильтрованном виде */
function kbListLength() {
return (typeof filteredData !== 'undefined' ? filteredData : allData).length;
}
/**
* activeRowIdx — позиция в filteredData (0-based).
* Конвертируем в allData-индекс для openModal.
*/
function kbFilteredToAllIdx(filteredIdx) {
const fd = typeof filteredData !== 'undefined' ? filteredData : allData;
const item = fd[filteredIdx];
if (!item) return null;
return allData.indexOf(item);
}
/** Установить активный индекс (в filteredData) и прокрутить строку */
function kbSetActive(idx) {
const len = kbListLength();
if (len === 0) return;
idx = Math.max(0, Math.min(idx, len - 1));
activeRowIdx = idx;
const allIdx = kbFilteredToAllIdx(idx);
kbHighlightRow(allIdx);
kbScrollRowIntoView(allIdx);
// Если боковая панель открыта — переключаем её на новую заявку
if (currentModalIdx !== null && allIdx !== null) {
openModal(allIdx);
}
}
/** Визуальное выделение строки по allData-индексу (data-idx в DOM) */
function kbHighlightRow(allIdx) {
document.querySelectorAll('.tasks-table tbody tr').forEach(tr => {
tr.classList.remove('kb-cursor');
});
const row = document.querySelector(`.tasks-table tbody tr[data-idx="${allIdx}"]`);
if (row) row.classList.add('kb-cursor');
}
/** Прокрутить строку (по allData-индексу) в видимую область */
function kbScrollRowIntoView(allIdx) {
const row = document.querySelector(`.tasks-table tbody tr[data-idx="${allIdx}"]`);
if (!row) return;
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 3: Управление боковым меню (ArrowRight / ArrowLeft)
// ═══════════════════════════════════════════════════════════════════
function kbHandleArrowRight() {
if (currentModalIdx === null) {
// Меню не открыто — открываем для активной строки
const filteredIdx = activeRowIdx ?? 0;
kbSetActive(filteredIdx); // убедимся что строка выделена
const allIdx = kbFilteredToAllIdx(filteredIdx);
if (allIdx !== null) openModal(allIdx);
} else {
// Меню уже открыто — переключаем на вкладку "Загрузить заявку"
if (currentTab !== 'history') {
switchTab('history');
}
}
}
function kbHandleArrowLeft() {
if (currentModalIdx === null) return; // меню не открыто — нечего делать
if (currentTab === 'history') {
// Открыта дополнительная вкладка → вернуть на основную
switchTab('last');
} else {
// Основная информация → закрыть меню
closeModal();
}
}
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 4: Быстрая смена статуса клавишами 16
// ═══════════════════════════════════════════════════════════════════
// Порядок статусов определяется полем kbShortcut в MARKS_CONFIG (statuses.js)
const KB_STATUS_MAP = Object.fromEntries(
MARKS_CONFIG.filter(m => m.kbShortcut).map(m => [m.kbShortcut, m.key])
);
async function kbSetStatus(digit) {
if (currentModalIdx === null) return; // меню должно быть открыто
const markType = KB_STATUS_MAP[digit];
if (!markType) return;
const item = allData[currentModalIdx];
if (!item) return;
const tid = String(item.task.id);
// Показать мгновенную визуальную индикацию
kbShowStatusFlash(digit, markType);
// Вызываем существующий setMark (optimistic update + запрос к серверу)
try {
await setMark(tid, markType, null);
} catch (err) {
// setMark делает optimistic update сам; ошибки сервера — через showToast внутри setMark
console.warn('[KB] setMark error:', err);
}
}
/** Мигающий тост-индикатор при смене статуса клавишей */
function kbShowStatusFlash(digit, markType) {
const cfg = MARKS_CONFIG.find(m => m.key === markType);
// Подсветить кнопку в боковом меню
const btn = document.querySelector(`.sp-marks .mark-btn[data-mark="${markType}"]`);
if (btn) {
btn.classList.add('kb-flash');
setTimeout(() => btn.classList.remove('kb-flash'), 600);
}
// Тост (если showToast уже есть в проекте — через sse.js)
if (typeof showToast === 'function') {
const label = cfg ? `${cfg.kbShortcut} · ${cfg.label}` : markType;
showToast(`${label}`, 'mark', 2000);
}
}
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 5: Центральный обработчик клавиатуры
// ═══════════════════════════════════════════════════════════════════
function kbHandleKeydown(e) {
// ── Игнорируем, если фокус в поле ввода ──────────────────────────
if (kbIsInputFocused()) return;
// ── Игнорируем с модификаторами Ctrl/Cmd/Alt (не ломаем браузерные шорткаты) ──
if (e.ctrlKey || e.metaKey || e.altKey) return;
const len = kbListLength();
if (len === 0) return;
// Если activeRowIdx не установлен — начинаем с 0
const cur = activeRowIdx ?? -1;
switch (e.key) {
// ── Навигация ───────────────────────────────────────────────────
case 'ArrowUp': {
e.preventDefault();
kbSetActive(cur <= 0 ? 0 : cur - 1);
break;
}
case 'ArrowDown': {
e.preventDefault();
kbSetActive(cur < 0 ? 0 : cur + 1);
break;
}
case 'PageUp': {
e.preventDefault();
kbSetActive(Math.max(0, cur - KB_PAGE_SIZE));
break;
}
case 'PageDown': {
e.preventDefault();
kbSetActive(cur < 0 ? KB_PAGE_SIZE : cur + KB_PAGE_SIZE);
break;
}
case 'Home': {
e.preventDefault();
kbSetActive(0);
break;
}
case 'End': {
e.preventDefault();
kbSetActive(len - 1);
break;
}
case 'Enter': {
// Enter открывает/закрывает боковое меню для текущей строки
if (cur >= 0) {
e.preventDefault();
const allIdx = kbFilteredToAllIdx(cur);
if (currentModalIdx === allIdx) {
closeModal();
} else if (allIdx !== null) {
openModal(allIdx);
}
}
break;
}
// ── Боковое меню ────────────────────────────────────────────────
case 'ArrowRight': {
e.preventDefault();
kbHandleArrowRight();
break;
}
case 'ArrowLeft': {
e.preventDefault();
kbHandleArrowLeft();
break;
}
// ── Быстрая смена статуса (только если меню открыто) ───────────
case '1': case '2': case '3':
case '4': case '5': case '6': {
if (currentModalIdx !== null) {
e.preventDefault();
kbSetStatus(e.key);
}
break;
}
// ── Escape: закрыть меню, снять курсор ─────────────────────────
// (Escape уже обрабатывается в ui.js через closeModal, но добавим снятие курсора)
case 'Escape': {
if (currentModalIdx !== null) {
// Меню закроет ui.js сам, но сбрасываем курсор
// activeRowIdx оставляем — юзер видит, где был
}
break;
}
default:
return; // не наша клавиша — не вызываем preventDefault
}
}
// ── Единственный глобальный слушатель ─────────────────────────────
// Capture: false (bubble) — достаточно, т.к. у нас нет stopPropagation в строках таблицы
document.addEventListener('keydown', kbHandleKeydown, false);
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 6: Синхронизация cursor при рендере таблицы
// Патчим renderTable() так, чтобы после рендера курсор восстанавливался.
// ═══════════════════════════════════════════════════════════════════
(function patchRenderTable() {
const _orig = window.renderTable;
if (typeof _orig !== 'function') {
// renderTable ещё не загружена — повторим при DOMContentLoaded
document.addEventListener('DOMContentLoaded', patchRenderTable);
return;
}
window.renderTable = function (...args) {
_orig.apply(this, args);
// После рендера восстановить подсветку курсора
if (activeRowIdx !== null) {
const allIdx = kbFilteredToAllIdx(activeRowIdx);
if (allIdx !== null) kbHighlightRow(allIdx);
}
};
})();
// ═══════════════════════════════════════════════════════════════════
// РАЗДЕЛ 7: Сброс activeRowIdx при клике мышью на строку таблицы
// Чтобы курсор и мышь не расходились.
// ═══════════════════════════════════════════════════════════════════
document.addEventListener('click', e => {
const row = e.target.closest('.tasks-table tbody tr[data-idx]');
if (!row) return;
const allIdx = parseInt(row.dataset.idx, 10);
if (!isNaN(allIdx)) {
// Find position in filteredData
const fd = typeof filteredData !== 'undefined' ? filteredData : allData;
const item = allData[allIdx];
const filteredIdx = item ? fd.indexOf(item) : -1;
if (filteredIdx >= 0) {
activeRowIdx = filteredIdx;
}
kbHighlightRow(allIdx);
}
}, false);