/* =============================================
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() {
// .palette-overlay ausklammern: das eigene (beim Schliessen noch deferred .active
// tragende) Overlay darf den Reopen-Guard nicht selbst blockieren (Self-Block-Race
// beim Toggle-Spam, da close() .active erst in withViewTransition entfernt).
return !!document.querySelector(
'.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active:not(.palette-overlay)'
);
}
// ---- 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;
// Sicherheit: nur sichere Protokolle oeffnen. Verhindert javascript:/data:-URLs aus
// importierten Bookmarks (XSS im Extension-Origin, besonders bei _self). http/https/ftp only.
let safe = false;
try { safe = ['http:', 'https:', 'ftp:'].includes(new URL(item.url).protocol); } catch (e) { /* ungueltige URL */ }
if (!safe) { close(); 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) {
// Kein role="option": der Leerzustand ist keine auswaehlbare Option, sondern eine
// Statuszeile. Die Ansage uebernimmt die aria-live-Region (liveEl) unten.
const empty = document.createElement('li');
empty.className = 'palette-empty';
empty.setAttribute('role', 'presentation');
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) {
// Guard: close() nullt overlay synchron, das DOM-Removal laeuft aber deferred in
// withViewTransition. In diesem Frame-Fenster kann ein mousemove auf einem noch
// lebenden Treffer-
setActive() ausloesen -> ohne diesen Guard Null-Deref auf overlay.
if (!overlay) return;
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', 'none');
// ARIA-1.2-Combobox: role=combobox gehoert auf das fokussierbare Textfeld selbst,
// nicht auf die Huelle. aria-expanded/haspopup/controls/activedescendant ebenfalls hier.
const input = document.createElement('input');
input.className = 'palette-input';
input.type = 'text';
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'true');
input.setAttribute('aria-haspopup', 'listbox');
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();
});
}
}