305 lines
15 KiB
JavaScript
305 lines
15 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════
|
||
// 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: Быстрая смена статуса клавишами 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);
|