227 lines
8.1 KiB
JavaScript
227 lines
8.1 KiB
JavaScript
/* =============================================
|
|
HELLION NEWTAB — palette.js
|
|
Command-Palette (Strg+K): read-only Suche ueber Boards/Bookmarks
|
|
============================================= */
|
|
|
|
// Reine init-Funktion ohne Top-Level-Seiteneffekt (Hausmuster wie initSearch).
|
|
// Wird aus app.js init() nach initSearch() aufgerufen.
|
|
function initPalette() {
|
|
let overlay = null; // aktives Overlay-Element oder null (= geschlossen)
|
|
let prevFocus = null; // Fokus-Rueckgabeziel
|
|
let keyHandler = null; // dokument-weiter Handler im offenen Zustand
|
|
let results = []; // aktuelle Trefferliste [{ title, url, boardName }]
|
|
let activeIndex = -1; // aktiver Listeneintrag fuer aria-activedescendant
|
|
|
|
// ---- Open-Guard: kein Strg+K wenn ein anderes Overlay offen ist ----
|
|
// Deckt Settings (.panel-overlay), Theme/Add-Board/Add-Bookmark/Rename
|
|
// (.modal-overlay) sowie HellionDialog UND Onboarding (.dialog-overlay) ab.
|
|
function isBlocked() {
|
|
return !!document.querySelector(
|
|
'.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active'
|
|
);
|
|
}
|
|
|
|
// ---- Trefferquelle: flach ueber alle Boards/Bookmarks ----
|
|
// Read-only auf dem globalen boards-Array. Match auf Titel, URL, Board-Name.
|
|
function search(query) {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
const out = [];
|
|
for (const board of boards) {
|
|
const boardName = board.title || '';
|
|
const boardMatch = boardName.toLowerCase().includes(q);
|
|
for (const bm of (board.bookmarks || [])) {
|
|
const title = bm.title || '';
|
|
const url = bm.url || '';
|
|
if (
|
|
title.toLowerCase().includes(q) ||
|
|
url.toLowerCase().includes(q) ||
|
|
boardMatch
|
|
) {
|
|
out.push({ title, url, boardName });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- Treffer oeffnen (wie boards.js:270) ----
|
|
function openResult(item) {
|
|
if (!item || !item.url) return;
|
|
window.open(item.url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
|
|
close();
|
|
}
|
|
|
|
// ---- Listbox neu rendern ----
|
|
function renderList(listEl, liveEl) {
|
|
listEl.textContent = '';
|
|
activeIndex = results.length ? 0 : -1;
|
|
|
|
if (results.length === 0) {
|
|
const empty = document.createElement('li');
|
|
empty.className = 'palette-empty';
|
|
empty.id = 'palette-opt-empty';
|
|
empty.setAttribute('role', 'option');
|
|
empty.setAttribute('aria-disabled', 'true');
|
|
empty.textContent = t('palette.no_results');
|
|
listEl.appendChild(empty);
|
|
liveEl.textContent = t('palette.no_results');
|
|
return;
|
|
}
|
|
|
|
results.forEach((item, i) => {
|
|
const li = document.createElement('li');
|
|
li.className = 'palette-option';
|
|
li.id = 'palette-opt-' + i;
|
|
li.setAttribute('role', 'option');
|
|
li.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
|
|
|
|
const titleSpan = document.createElement('span');
|
|
titleSpan.className = 'palette-option-title';
|
|
titleSpan.textContent = item.title;
|
|
|
|
const metaSpan = document.createElement('span');
|
|
metaSpan.className = 'palette-option-meta';
|
|
metaSpan.textContent = t('palette.board_prefix') + ' ' + item.boardName;
|
|
|
|
li.append(titleSpan, metaSpan);
|
|
// Pointer-Auswahl: Klick oeffnet, Hover markiert
|
|
li.addEventListener('click', () => openResult(item));
|
|
li.addEventListener('mousemove', () => setActive(listEl, i));
|
|
listEl.appendChild(li);
|
|
});
|
|
|
|
const count = results.length;
|
|
liveEl.textContent = count === 1 ? t('palette.count_one') : t('palette.count', { count });
|
|
}
|
|
|
|
// ---- aktiven Eintrag setzen (aria-activedescendant + aria-selected) ----
|
|
function setActive(listEl, idx) {
|
|
const options = listEl.querySelectorAll('.palette-option');
|
|
if (options.length === 0) return;
|
|
activeIndex = Math.max(0, Math.min(idx, options.length - 1));
|
|
options.forEach((opt, i) => {
|
|
opt.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
|
|
});
|
|
const input = overlay.querySelector('.palette-input');
|
|
input.setAttribute('aria-activedescendant', options[activeIndex].id);
|
|
options[activeIndex].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
// ---- schliessen: dialog.js-Cleanup-Muster (remove Listener -> Transition -> Fokus) ----
|
|
function close() {
|
|
if (!overlay) return;
|
|
document.removeEventListener('keydown', keyHandler);
|
|
const el = overlay;
|
|
overlay = null;
|
|
keyHandler = null;
|
|
withViewTransition(() => {
|
|
el.classList.remove('active');
|
|
el.remove();
|
|
});
|
|
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
|
|
prevFocus = null;
|
|
}
|
|
|
|
// ---- oeffnen: Overlay nach dialog.js-Muster aufbauen ----
|
|
function open() {
|
|
if (overlay) return;
|
|
prevFocus = document.activeElement;
|
|
|
|
overlay = document.createElement('div');
|
|
overlay.className = 'dialog-overlay palette-overlay';
|
|
|
|
const box = document.createElement('div');
|
|
box.className = 'palette-box';
|
|
box.setAttribute('role', 'combobox');
|
|
box.setAttribute('aria-haspopup', 'listbox');
|
|
box.setAttribute('aria-expanded', 'true');
|
|
|
|
const input = document.createElement('input');
|
|
input.className = 'palette-input';
|
|
input.type = 'text';
|
|
input.setAttribute('role', 'searchbox');
|
|
input.setAttribute('aria-label', t('palette.aria_label'));
|
|
input.setAttribute('aria-autocomplete', 'list');
|
|
input.setAttribute('aria-controls', 'palette-listbox');
|
|
input.placeholder = t('palette.placeholder');
|
|
input.autocomplete = 'off';
|
|
input.spellcheck = false;
|
|
|
|
const list = document.createElement('ul');
|
|
list.className = 'palette-list';
|
|
list.id = 'palette-listbox';
|
|
list.setAttribute('role', 'listbox');
|
|
list.setAttribute('aria-label', t('palette.list_label'));
|
|
|
|
const live = document.createElement('div');
|
|
live.className = 'palette-live';
|
|
live.setAttribute('aria-live', 'polite');
|
|
|
|
const hint = document.createElement('div');
|
|
hint.className = 'palette-hint';
|
|
hint.textContent = t('palette.hint');
|
|
|
|
box.append(input, list, live, hint);
|
|
overlay.appendChild(box);
|
|
|
|
// Klick auf den Overlay-Hintergrund schliesst (wie dialog.js:107)
|
|
overlay.addEventListener('click', e => {
|
|
if (e.target === overlay) close();
|
|
});
|
|
|
|
// Live-Filter
|
|
input.addEventListener('input', () => {
|
|
results = search(input.value);
|
|
renderList(list, live);
|
|
input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-opt-' + activeIndex : '');
|
|
});
|
|
|
|
// Tastatursteuerung: Pfeile/Enter/Escape + Fokus-Falle (nur input fokussierbar)
|
|
keyHandler = function (e) {
|
|
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (activeIndex >= 0 && results[activeIndex]) openResult(results[activeIndex]);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(list, activeIndex + 1); return; }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(list, activeIndex - 1); return; }
|
|
if (e.key === 'Tab') {
|
|
// Fokus-Falle ueber den Container: das Eingabefeld ist das einzige
|
|
// fokussierbare Element, also Tab/Shift+Tab immer dorthin zurueck.
|
|
e.preventDefault();
|
|
input.focus();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', keyHandler);
|
|
|
|
document.body.appendChild(overlay);
|
|
// View-Transition uebernimmt das Fade; Fokus ins Eingabefeld
|
|
withViewTransition(() => {
|
|
overlay.classList.add('active');
|
|
input.focus();
|
|
});
|
|
}
|
|
|
|
// ---- globaler Ausloeser Strg+K (Meta+K auf Mac) mit Open-Guard ----
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
|
if (overlay) { e.preventDefault(); close(); return; }
|
|
if (isBlocked()) return;
|
|
e.preventDefault();
|
|
open();
|
|
}
|
|
});
|
|
|
|
// Persistenter Header-Trigger (BS-08): Klick toggelt die Palette wie Strg+K.
|
|
const paletteBtn = document.getElementById('btnPalette');
|
|
if (paletteBtn) {
|
|
paletteBtn.addEventListener('click', () => {
|
|
if (overlay) { close(); return; }
|
|
if (isBlocked()) return;
|
|
open();
|
|
});
|
|
}
|
|
}
|