// ═══════════════════════════════════════════════════════════════════ // keyboard.js — Централизованный менеджер клавиатурных шорткатов // Подключить в index.html ПОСЛЕ всех остальных скриптов: // // ═══════════════════════════════════════════════════════════════════ // ── Константы навигации ──────────────────────────────────────────── 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: Быстрая смена статуса клавишами 1–6 // ═══════════════════════════════════════════════════════════════════ // Порядок статусов определяется полем 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);