mirror of
https://gh.wpcy.net/https://github.com/CaptainCore/Helm.git
synced 2026-04-22 05:22:45 +08:00
1610 lines
No EOL
65 KiB
JavaScript
1610 lines
No EOL
65 KiB
JavaScript
/* CaptainCore Helm – JS */
|
||
|
||
(() => {
|
||
const CFG = window.CCHELM_CONFIG || {};
|
||
const CORE_IDS = new Set(CFG.coreIds || []);
|
||
const TOOLBAR_KEEP = new Set(CFG.toolbarKeepIds || []);
|
||
const UPDATES_COUNT = Number(CFG.updatesCount || 0);
|
||
const VIEW_KEY = 'cch:view'; // 'cards' | 'expanded'
|
||
const THEME_KEY = 'cch:theme'; // 'light' | 'dark'
|
||
|
||
const qs = (sel, ctx = document) => ctx.querySelector(sel);
|
||
const qsa = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
|
||
|
||
const isMac = () => {
|
||
const ua = navigator.userAgent || '';
|
||
const plt = navigator.platform || '';
|
||
return /Mac|iPhone|iPad|iPod/i.test(ua) || /Mac/i.test(plt);
|
||
};
|
||
|
||
const applyExternalLinkStyles = (element, href) => {
|
||
try {
|
||
const linkUrl = new URL(href, window.location.origin);
|
||
if (linkUrl.hostname !== window.location.hostname) {
|
||
element.target = '_blank';
|
||
element.rel = 'noopener noreferrer';
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
// Invalid URL
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// --- SHORTCUT CONFIGURATION START ---
|
||
const getShortcutLabel = () => (isMac() ? '⌘⌥K' : 'Ctrl+Alt+K');
|
||
|
||
const isOpenShortcut = (e) => {
|
||
// Check for 'k' or 'K'
|
||
const isK = (e.key && e.key.toLowerCase() === 'k') ||
|
||
(e.code && e.code.toLowerCase() === 'keyk') ||
|
||
e.keyCode === 75;
|
||
|
||
if (!isK) return false;
|
||
|
||
if (isMac()) {
|
||
// Mac: Cmd + Option + K
|
||
return e.metaKey && e.altKey && !e.ctrlKey && !e.shiftKey;
|
||
} else {
|
||
// Windows/Linux: Ctrl + Alt + K
|
||
return e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey;
|
||
}
|
||
};
|
||
// --- SHORTCUT CONFIGURATION END ---
|
||
|
||
const visibleText = (el) => {
|
||
if (!el) return '';
|
||
const clone = el.cloneNode(true);
|
||
qsa(
|
||
'.screen-reader-text, .awaiting-mod, .update-plugins, .count, ' +
|
||
'.plugin-count, .wp-ui-notification',
|
||
clone
|
||
).forEach((n) => n.remove());
|
||
return (clone.textContent || '').trim();
|
||
};
|
||
|
||
const sanitizeLabel = (s) => {
|
||
if (!s) return '';
|
||
s = String(s).replace(/\s+/g, ' ').trim();
|
||
s = s.replace(/(?:[\s\u00a0]*[()\[\]•·|:\-\u2013\u2014]*)?\d+(?:\+)?$/u, '');
|
||
return s.trim();
|
||
};
|
||
|
||
const escHtml = (s) =>
|
||
String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
const highlightInto = (el, raw, q) => {
|
||
if (!el) return;
|
||
if (!q) {
|
||
el.textContent = raw;
|
||
return;
|
||
}
|
||
const src = String(raw);
|
||
const hay = src.toLowerCase();
|
||
const needle = q.toLowerCase();
|
||
if (!needle) {
|
||
el.textContent = src;
|
||
return;
|
||
}
|
||
let i = 0;
|
||
let html = '';
|
||
while (true) {
|
||
const j = hay.indexOf(needle, i);
|
||
if (j === -1) {
|
||
html += escHtml(src.slice(i));
|
||
break;
|
||
}
|
||
html += escHtml(src.slice(i, j));
|
||
html +=
|
||
'<mark class="cch-hl">' +
|
||
escHtml(src.slice(j, j + needle.length)) +
|
||
'</mark>';
|
||
i = j + needle.length;
|
||
}
|
||
el.innerHTML = html;
|
||
};
|
||
|
||
/* --- Icon Helpers --- */
|
||
const copyIcon = (li) => {
|
||
const src = qs('.wp-menu-image', li);
|
||
const span = document.createElement('span');
|
||
span.className = 'cch-icon';
|
||
if (!src) return span;
|
||
const classes = Array.from(src.classList).filter((c) => c.startsWith('dashicons'));
|
||
if (classes.length) {
|
||
span.classList.add(...classes);
|
||
if (!span.classList.contains('dashicons-before')) span.classList.add('dashicons-before');
|
||
}
|
||
const bg = src.getAttribute('style') || '';
|
||
const m = /background-image:\s*url\(([^)]+)\)/i.exec(bg);
|
||
if (m) {
|
||
span.style.backgroundImage = 'url(' + m[1] + ')';
|
||
span.style.backgroundSize = 'cover';
|
||
}
|
||
const svg = qs('svg', src);
|
||
if (svg) span.appendChild(svg.cloneNode(true));
|
||
return span;
|
||
};
|
||
|
||
const makeDashiconIcon = (dash) => {
|
||
const span = document.createElement('span');
|
||
span.className = 'cch-icon';
|
||
if (typeof dash === 'string' && dash.startsWith('dashicons-')) {
|
||
span.classList.add('dashicons-before', dash);
|
||
} else {
|
||
span.classList.add('dashicons-before', 'dashicons-admin-generic');
|
||
}
|
||
return span;
|
||
};
|
||
|
||
const addSafeClasses = (el, list) => {
|
||
(list || [])
|
||
.map((c) => String(c).trim())
|
||
.filter((c) => c && /^[A-Za-z0-9_\-:]+$/.test(c) && c.length < 128)
|
||
.forEach((c) => el.classList.add(c));
|
||
};
|
||
|
||
const makeIconFromSnapshot = (it) => {
|
||
const span = document.createElement('span');
|
||
span.className = 'cch-icon';
|
||
const bg = (it.iconBg || '').trim();
|
||
const svg = (it.iconSvg || '').trim();
|
||
let sig = (it.iconClass || '').trim();
|
||
|
||
if (!bg && /^data:image\//i.test(sig)) {
|
||
span.style.backgroundImage = `url(${sig})`;
|
||
span.style.backgroundSize = 'cover';
|
||
return span;
|
||
}
|
||
if (!bg && /^url\(/i.test(sig)) {
|
||
span.style.backgroundImage = sig;
|
||
span.style.backgroundSize = 'cover';
|
||
return span;
|
||
}
|
||
if (!bg && /\.(svg|png|jpe?g|gif)(\?.*)?$/i.test(sig)) {
|
||
span.style.backgroundImage = `url(${sig})`;
|
||
span.style.backgroundSize = 'cover';
|
||
return span;
|
||
}
|
||
if (bg) {
|
||
if (/^url\(/i.test(bg)) span.style.backgroundImage = bg;
|
||
else span.style.backgroundImage = `url(${bg})`;
|
||
span.style.backgroundSize = 'cover';
|
||
return span;
|
||
}
|
||
if (svg) {
|
||
span.innerHTML = svg;
|
||
return span;
|
||
}
|
||
if (sig.startsWith('dashicons')) {
|
||
addSafeClasses(span, ['dashicons-before', sig]);
|
||
return span;
|
||
}
|
||
addSafeClasses(span, ['dashicons-before', 'dashicons-admin-generic']);
|
||
return span;
|
||
};
|
||
|
||
/* --- Loading State Logic --- */
|
||
const triggerLoading = (href) => {
|
||
const toggle = document.getElementById('cch-island-toggle');
|
||
const popout = document.getElementById('cch-popout');
|
||
const label = toggle ? toggle.querySelector('.cch-island-label') : null;
|
||
const icon = toggle ? toggle.querySelector('.cch-island-icon') : null;
|
||
const loader = toggle ? toggle.querySelector('.cch-island-loader') : null;
|
||
|
||
// Close popout
|
||
if (popout) popout.setAttribute('aria-hidden', 'true');
|
||
document.body.classList.remove('cch-lock-scroll');
|
||
|
||
// Set Island to loading state
|
||
if (toggle && label) {
|
||
toggle.classList.add('cch-loading');
|
||
|
||
// 1. Swap icon for loader
|
||
if(icon) icon.style.display = 'none';
|
||
if(loader) loader.style.display = 'block';
|
||
|
||
// 2. Format nice text for the label
|
||
try {
|
||
const url = new URL(href, window.location.origin);
|
||
const isExternal = url.origin !== window.location.origin;
|
||
let displayText = '';
|
||
|
||
if (isExternal) {
|
||
// Show hostname for external links
|
||
displayText = url.hostname + url.pathname;
|
||
} else {
|
||
// Internal: show pathname + search
|
||
displayText = url.pathname + url.search;
|
||
|
||
// Only simplify if there's more content after the prefix
|
||
const simplified = displayText
|
||
.replace(/^\/wp-admin\/(?=.+)/, '')
|
||
.replace(/^admin\.php\?page=/, '');
|
||
|
||
if (simplified && simplified !== displayText) {
|
||
displayText = simplified;
|
||
}
|
||
}
|
||
|
||
// Truncate if too long
|
||
if (displayText.length > 58) displayText = displayText.substring(0, 55) + '...';
|
||
|
||
label.textContent = 'Loading ' + displayText;
|
||
} catch(e) {
|
||
label.textContent = 'Loading...';
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleLinkClick = (e) => {
|
||
const a = e.target.closest('a');
|
||
if (!a || !a.href || a.getAttribute('href') === '#') return;
|
||
if (a.target === '_blank' || e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||
|
||
e.preventDefault();
|
||
triggerLoading(a.href);
|
||
|
||
// Check if we are in Divi (et_fb=1) or inside an iframe
|
||
const isDiviBuilder = window.location.search.includes('et_fb=1');
|
||
const isIframe = window.self !== window.top;
|
||
|
||
if (isDiviBuilder || isIframe) {
|
||
// Force navigation on the parent window to break out of the builder
|
||
window.top.location.href = a.href;
|
||
} else {
|
||
// Standard navigation
|
||
window.location.href = a.href;
|
||
}
|
||
};
|
||
|
||
/* --- Collectors --- */
|
||
const isSystemMenu = (li) => {
|
||
if (!li || !li.id) return false;
|
||
if (CORE_IDS.has(li.id)) return true;
|
||
try {
|
||
const a = qs('a', li);
|
||
const href = a ? new URL(a.href, location.origin).pathname : '';
|
||
return [
|
||
'/wp-admin/index.php',
|
||
'/wp-admin/upload.php',
|
||
'/wp-admin/edit.php',
|
||
'/wp-admin/edit.php?post_type=page',
|
||
'/wp-admin/themes.php',
|
||
'/wp-admin/plugins.php',
|
||
'/wp-admin/users.php',
|
||
'/wp-admin/tools.php',
|
||
'/wp-admin/options-general.php',
|
||
].some((p) => href.startsWith(p));
|
||
} catch { return false; }
|
||
};
|
||
|
||
const collectLeftMenus = () => {
|
||
const byLabel = (a, b) => a.label.localeCompare(b.label);
|
||
const system = [];
|
||
const extensions = [];
|
||
const hasSnap = Array.isArray(CFG.menuSnapshot) && CFG.menuSnapshot.length > 0;
|
||
|
||
if (hasSnap) {
|
||
const snap = CFG.menuSnapshot;
|
||
snap.forEach((it) => {
|
||
const item = {
|
||
label: sanitizeLabel(it.label || ''),
|
||
href: it.href || '#',
|
||
subs: Array.isArray(it.subs) ?
|
||
it.subs.map((s) => ({
|
||
label: sanitizeLabel(s.label || ''),
|
||
href: s.href || '#',
|
||
})) : [],
|
||
icon: makeIconFromSnapshot(it),
|
||
id: it.id || '',
|
||
};
|
||
if (item.id && CORE_IDS.has(item.id)) system.push(item);
|
||
else extensions.push(item);
|
||
});
|
||
system.sort(byLabel);
|
||
extensions.sort(byLabel);
|
||
return { system, extensions };
|
||
}
|
||
|
||
const adminMenuRoot = document.querySelector('#adminmenu');
|
||
if (adminMenuRoot) {
|
||
const items = Array.from(document.querySelectorAll('#adminmenu > li.menu-top')).filter(
|
||
(li) => !li.classList.contains('wp-menu-separator') && li.id !== 'collapse-menu'
|
||
);
|
||
items.forEach((li) => {
|
||
const a = li.querySelector('a');
|
||
if (!a) return;
|
||
const nameEl = li.querySelector('.wp-menu-name') || a;
|
||
const label = sanitizeLabel(visibleText(nameEl));
|
||
const href = a.getAttribute('href') || '#';
|
||
const subs = Array.from(li.querySelectorAll('ul.wp-submenu-wrap > li > a'))
|
||
.filter((x) => !x.classList.contains('wp-submenu-head'))
|
||
.map((x) => ({
|
||
label: sanitizeLabel(visibleText(x)),
|
||
href: x.getAttribute('href') || '#',
|
||
}));
|
||
const icon = copyIcon(li);
|
||
const item = { label, href, icon, subs, id: li.id || '' };
|
||
if (isSystemMenu(li)) system.push(item);
|
||
else extensions.push(item);
|
||
});
|
||
system.sort(byLabel);
|
||
extensions.sort(byLabel);
|
||
}
|
||
return { system, extensions };
|
||
};
|
||
|
||
const collectToolbar = () => {
|
||
const roots = [
|
||
qs('#wp-admin-bar-root-default'),
|
||
qs('#wp-admin-bar-top-secondary'),
|
||
].filter(Boolean);
|
||
const extras = [];
|
||
const system = [];
|
||
const SKIP_IDS = new Set(
|
||
Array.isArray(CFG.toolbarSkipIds) && CFG.toolbarSkipIds.length ?
|
||
CFG.toolbarSkipIds :
|
||
['wp-admin-bar-menu-toggle', 'wp-admin-bar-search', 'wp-admin-bar-cch-popout-toggle']
|
||
);
|
||
const ICON_MAP = Object.assign({
|
||
'wp-admin-bar-site-name': 'dashicons-admin-home',
|
||
'wp-admin-bar-new-content': 'dashicons-plus',
|
||
'wp-admin-bar-comments': 'dashicons-admin-comments',
|
||
'wp-admin-bar-updates': 'dashicons-update',
|
||
'wp-admin-bar-customize': 'dashicons-admin-customize',
|
||
'wp-admin-bar-search': 'dashicons-search',
|
||
'wp-admin-bar-my-account': 'dashicons-admin-users',
|
||
'wp-admin-bar-edit': 'dashicons-edit',
|
||
}, CFG.toolbarIconMap || {});
|
||
|
||
roots.forEach((root) => {
|
||
qsa(':scope > li', root).forEach((li) => {
|
||
const id = li.id || '';
|
||
if (SKIP_IDS.has(id)) return;
|
||
const anchor = qs('a.ab-item', li) || qs('.ab-item', li) || qs('[role="menuitem"]', li);
|
||
if (!anchor) return;
|
||
const subs = qsa('.ab-submenu a', li).map((x) => {
|
||
let label = sanitizeLabel(visibleText(x));
|
||
const href = x.getAttribute('href') || '#';
|
||
if (href.includes('profile.php') || href.includes('user-edit.php')) label = 'Edit Profile';
|
||
return { label: label, href: href };
|
||
});
|
||
let label = sanitizeLabel(visibleText(anchor));
|
||
if (!label) {
|
||
const aria = (anchor.getAttribute('aria-label') || '').trim();
|
||
const title = (anchor.getAttribute('title') || '').trim();
|
||
label = sanitizeLabel(aria || title);
|
||
}
|
||
if (!label) {
|
||
const sr = qs('.screen-reader-text', anchor);
|
||
if (sr) label = sanitizeLabel((sr.textContent || '').trim());
|
||
}
|
||
if (!label && subs.length) label = subs[0].label || '';
|
||
let href = anchor.getAttribute('href') || '';
|
||
if (!href && subs.length) href = subs[0].href || '#';
|
||
if (!href) href = '#';
|
||
if (!label && subs.length === 0) return;
|
||
const item = { id, label, href, subs, icon: null };
|
||
const mapped = ICON_MAP[id];
|
||
if (mapped) item.icon = makeDashiconIcon(mapped);
|
||
if (!item.icon) {
|
||
const candidates = [ anchor, anchor.querySelector('.dashicons'), anchor.querySelector("[class*='dashicons-']") ].filter(Boolean);
|
||
for (const el of candidates) {
|
||
const found = Array.from(el.classList).find((c) => c.startsWith('dashicons-'));
|
||
if (found) { item.icon = makeDashiconIcon(found); break; }
|
||
}
|
||
}
|
||
if (TOOLBAR_KEEP.has(id)) system.push(item);
|
||
else extras.push(item);
|
||
});
|
||
});
|
||
const byLabel = (a, b) => a.label.localeCompare(b.label);
|
||
system.sort(byLabel);
|
||
extras.sort(byLabel);
|
||
return { system, extras };
|
||
};
|
||
|
||
/* --- Builders --- */
|
||
|
||
const buildCard = (item) => {
|
||
// Card is a container div, main link is inside
|
||
const card = document.createElement('div');
|
||
card.className = 'cch-card';
|
||
card.setAttribute('data-label', (item.label || '').toLowerCase());
|
||
|
||
// Main link wrapper (header)
|
||
const mainLink = document.createElement('a');
|
||
mainLink.className = 'cch-card-main-link';
|
||
mainLink.href = item.href || '#';
|
||
|
||
const isExternalCard = applyExternalLinkStyles(mainLink, item.href);
|
||
|
||
const iconSlot = item.icon || document.createElement('span');
|
||
const content = document.createElement('div');
|
||
const title = document.createElement('div');
|
||
const meta = document.createElement('div');
|
||
|
||
title.className = 'cch-label';
|
||
title.textContent = item.label || '';
|
||
title.dataset.cchRaw = item.label || '';
|
||
|
||
if (isExternalCard) {
|
||
const icon = document.createElement('span');
|
||
icon.className = 'dashicons dashicons-external cch-external-link-icon';
|
||
title.appendChild(icon);
|
||
}
|
||
|
||
meta.className = 'cch-meta';
|
||
if (typeof item.meta === 'string') {
|
||
meta.textContent = item.meta;
|
||
} else if (typeof item.updateCount === 'number') {
|
||
meta.textContent = item.updateCount + ' updates';
|
||
} else {
|
||
meta.textContent = item.subs && item.subs.length ? item.subs.length + ' shortcuts' : 'Open';
|
||
}
|
||
|
||
content.appendChild(title);
|
||
content.appendChild(meta);
|
||
|
||
// Build main link
|
||
mainLink.addEventListener('click', handleLinkClick);
|
||
mainLink.appendChild(iconSlot);
|
||
mainLink.appendChild(content);
|
||
|
||
// Add main link to card
|
||
card.appendChild(mainLink);
|
||
|
||
if (item.subs && item.subs.length) {
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'cch-submenu';
|
||
item.subs.forEach((s) => {
|
||
const li = document.createElement('li');
|
||
const a = document.createElement('a');
|
||
a.href = s.href || '#';
|
||
a.textContent = s.label || '';
|
||
a.dataset.cchRaw = s.label || '';
|
||
|
||
const isExternalSub = applyExternalLinkStyles(a, s.href);
|
||
if (isExternalSub) {
|
||
const icon = document.createElement('span');
|
||
icon.className = 'dashicons dashicons-external cch-external-link-icon';
|
||
a.appendChild(icon);
|
||
}
|
||
a.addEventListener('click', (e) => {
|
||
e.stopPropagation(); // prevent card click
|
||
handleLinkClick(e);
|
||
});
|
||
li.appendChild(a);
|
||
ul.appendChild(li);
|
||
});
|
||
card.appendChild(ul);
|
||
// Prevent main card click when clicking into the UL white space
|
||
ul.addEventListener('click', (e) => e.stopPropagation());
|
||
}
|
||
|
||
return card;
|
||
};
|
||
|
||
const buildSection = (title, items) => {
|
||
if (!items.length) return null;
|
||
const section = document.createElement('section');
|
||
section.className = 'cch-section';
|
||
|
||
const h = document.createElement('h3');
|
||
h.textContent = title;
|
||
|
||
const grid = document.createElement('div');
|
||
grid.className = 'cch-grid';
|
||
|
||
items.forEach((it) => grid.appendChild(buildCard(it)));
|
||
|
||
section.appendChild(h);
|
||
section.appendChild(grid);
|
||
return section;
|
||
};
|
||
|
||
const buildPopout = () => {
|
||
const pop = document.createElement('div');
|
||
pop.id = 'cch-popout';
|
||
pop.setAttribute('role', 'dialog');
|
||
pop.setAttribute('aria-modal', 'true');
|
||
pop.setAttribute('aria-hidden', 'true');
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'cch-popout-inner';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'cch-popout-header';
|
||
|
||
const title = document.createElement('h2');
|
||
title.id = 'cch-popout-title';
|
||
title.textContent = 'Quick Menu';
|
||
|
||
const search = document.createElement('input');
|
||
search.type = 'search';
|
||
search.id = 'cch-popout-search';
|
||
search.placeholder = 'Filter apps and actions...';
|
||
|
||
const viewToggle = document.createElement('div');
|
||
viewToggle.className = 'cch-view-toggle';
|
||
viewToggle.setAttribute('role', 'group');
|
||
viewToggle.setAttribute('aria-label', 'Layout');
|
||
const btnCards = document.createElement('button');
|
||
btnCards.type = 'button';
|
||
btnCards.setAttribute('aria-label', 'Cards view');
|
||
btnCards.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="6" height="6" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="9" y="1" width="6" height="6" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="1" y="9" width="6" height="6" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="9" y="9" width="6" height="6" rx="1" stroke="currentColor" stroke-width="1.5"/></svg><span>Cards</span>';
|
||
const btnExpanded = document.createElement('button');
|
||
btnExpanded.type = 'button';
|
||
btnExpanded.setAttribute('aria-label', 'Expanded view');
|
||
btnExpanded.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="4" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="1" y="7" width="14" height="4" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="1" y="13" width="14" height="2" rx="1" stroke="currentColor" stroke-width="1.5"/></svg><span>Expanded</span>';
|
||
viewToggle.appendChild(btnCards);
|
||
viewToggle.appendChild(btnExpanded);
|
||
|
||
const modeBtn = document.createElement('button');
|
||
modeBtn.type = 'button';
|
||
modeBtn.className = 'cch-btn-icon cch-mode-toggle';
|
||
modeBtn.title = 'Toggle Dark Mode';
|
||
// Icon set via function below
|
||
|
||
const helpBtn = document.createElement('button');
|
||
helpBtn.type = 'button';
|
||
helpBtn.className = 'cch-btn-icon cch-help';
|
||
helpBtn.setAttribute('aria-haspopup', 'dialog');
|
||
helpBtn.setAttribute('aria-expanded', 'false');
|
||
helpBtn.title = 'Help';
|
||
helpBtn.textContent = '?';
|
||
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'cch-btn-icon cch-close';
|
||
close.setAttribute('aria-label', 'Close menu');
|
||
close.innerHTML = '<span class="dashicons dashicons-no-alt"></span>';
|
||
|
||
header.appendChild(title);
|
||
header.appendChild(search);
|
||
header.appendChild(viewToggle);
|
||
header.appendChild(modeBtn);
|
||
header.appendChild(helpBtn);
|
||
header.appendChild(close);
|
||
|
||
const sections = document.createElement('div');
|
||
sections.className = 'cch-sections';
|
||
|
||
// Data collection
|
||
const { system, extensions } = collectLeftMenus();
|
||
const { system: toolbarSystem, extras: toolbarExtras } = collectToolbar();
|
||
|
||
// Integrate Updates
|
||
const updatesIdx = toolbarExtras.findIndex((it) => it.id === 'wp-admin-bar-updates');
|
||
if (updatesIdx > -1) {
|
||
const u = toolbarExtras.splice(updatesIdx, 1)[0];
|
||
const icon = makeDashiconIcon('dashicons-update');
|
||
const hasUpdates = UPDATES_COUNT > 0;
|
||
const updatesItem = {
|
||
label: hasUpdates ? 'Updates available' : 'Up to date',
|
||
href: u.href || '#',
|
||
subs: u.subs || [],
|
||
icon,
|
||
updateCount: UPDATES_COUNT,
|
||
};
|
||
system.unshift(updatesItem);
|
||
}
|
||
|
||
// Integrate WooCommerce
|
||
const wcIdx = toolbarExtras.findIndex((it) => it.id === 'wp-admin-bar-woocommerce-site-visibility-badge');
|
||
if (wcIdx > -1) {
|
||
const w = toolbarExtras.splice(wcIdx, 1)[0];
|
||
const iconW = makeDashiconIcon('dashicons-cart');
|
||
extensions.unshift({ label: w.label || 'WooCommerce', href: w.href || '#', subs: w.subs || [], icon: iconW });
|
||
}
|
||
|
||
// Fallback Edit link when no homepage is assigned or on archive pages
|
||
const editIdx = toolbarSystem.findIndex((it) => it.id === 'wp-admin-bar-edit');
|
||
const editItem = editIdx > -1 ? toolbarSystem[editIdx] : null;
|
||
const hasValidEdit = editItem && editItem.href && editItem.href !== '#' && editItem.href.includes('post.php');
|
||
if (!hasValidEdit) {
|
||
// Remove invalid edit item if present
|
||
if (editIdx > -1) toolbarSystem.splice(editIdx, 1);
|
||
const editIcon = makeDashiconIcon('dashicons-edit');
|
||
toolbarSystem.push({
|
||
id: 'wp-admin-bar-edit-fallback',
|
||
label: 'Edit',
|
||
href: '/wp-admin/',
|
||
subs: [],
|
||
icon: editIcon,
|
||
});
|
||
}
|
||
|
||
const s1 = buildSection('System', system);
|
||
const s2 = buildSection('Extensions', extensions);
|
||
const s3 = toolbarExtras.length ? buildSection('Toolbar Extras', toolbarExtras) : null;
|
||
const s4 = buildSection('Toolbar', toolbarSystem);
|
||
|
||
if (s1) sections.appendChild(s1);
|
||
if (s2) sections.appendChild(s2);
|
||
if (s3) sections.appendChild(s3);
|
||
if (s4) sections.appendChild(s4);
|
||
|
||
wrap.appendChild(header);
|
||
wrap.appendChild(sections);
|
||
pop.appendChild(wrap);
|
||
|
||
// Help Overlay
|
||
const help = document.createElement('div');
|
||
help.id = 'cch-help';
|
||
help.setAttribute('aria-hidden', 'true');
|
||
const helpPanel = document.createElement('div');
|
||
helpPanel.className = 'cch-help-panel';
|
||
const helpTitle = document.createElement('h3');
|
||
helpTitle.textContent = 'Keyboard shortcuts';
|
||
const shortcut = getShortcutLabel();
|
||
const helplist = document.createElement('ul');
|
||
helplist.className = 'cch-help-list';
|
||
const items = [
|
||
`Open / Close menu: ${shortcut}`,
|
||
'Navigate: Arrow keys',
|
||
'Open selection: Enter',
|
||
'Filter: Type to search',
|
||
'Close: Esc',
|
||
];
|
||
items.forEach((txt) => {
|
||
const li = document.createElement('li');
|
||
li.textContent = txt;
|
||
helplist.appendChild(li);
|
||
});
|
||
const helpClose = document.createElement('button');
|
||
helpClose.type = 'button';
|
||
helpClose.className = 'cch-help-close';
|
||
helpClose.textContent = 'Close help';
|
||
helpPanel.appendChild(helpTitle);
|
||
helpPanel.appendChild(helplist);
|
||
helpPanel.appendChild(helpClose);
|
||
help.appendChild(helpPanel);
|
||
pop.appendChild(help);
|
||
|
||
document.body.appendChild(pop);
|
||
|
||
/* --- Helper Functions --- */
|
||
const showHelp = () => {
|
||
help.setAttribute('aria-hidden', 'false');
|
||
helpBtn.setAttribute('aria-expanded', 'true');
|
||
const btn = qs('.cch-help-close', help) || help;
|
||
btn.focus();
|
||
};
|
||
|
||
const hideHelp = () => {
|
||
help.setAttribute('aria-hidden', 'true');
|
||
helpBtn.setAttribute('aria-expanded', 'false');
|
||
helpBtn.focus();
|
||
};
|
||
|
||
const hide = () => {
|
||
pop.setAttribute('aria-hidden', 'true');
|
||
document.body.classList.remove('cch-lock-scroll');
|
||
setKeyboardMode(false);
|
||
hideHelp();
|
||
};
|
||
|
||
const show = () => {
|
||
pop.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('cch-lock-scroll');
|
||
setKeyboardMode(false);
|
||
// Autofocus Search
|
||
requestAnimationFrame(() => {
|
||
setTimeout(() => { search.focus(); }, 10);
|
||
});
|
||
};
|
||
|
||
// --- Event Listeners ---
|
||
helpBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
if (help.getAttribute('aria-hidden') === 'false') hideHelp();
|
||
else showHelp();
|
||
});
|
||
help.addEventListener('click', (e) => {
|
||
if (e.target === help) hideHelp();
|
||
});
|
||
helpClose.addEventListener('click', hideHelp);
|
||
|
||
close.addEventListener('click', hide);
|
||
pop.addEventListener('click', (e) => {
|
||
if (e.target === pop || e.target === wrap) hide();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && pop.getAttribute('aria-hidden') === 'false') {
|
||
if (help.getAttribute('aria-hidden') === 'false') hideHelp();
|
||
else hide();
|
||
}
|
||
});
|
||
|
||
// Dark Mode
|
||
const sunIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
|
||
const moonIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||
const updateModeIcon = (isDark) => {
|
||
modeBtn.innerHTML = isDark ? sunIcon : moonIcon;
|
||
modeBtn.title = isDark ? "Switch to Light Mode" : "Switch to Dark Mode";
|
||
};
|
||
const applyTheme = (theme) => {
|
||
const isDark = theme === 'dark';
|
||
document.body.classList.toggle('cch-dark-mode', isDark);
|
||
localStorage.setItem(THEME_KEY, theme);
|
||
updateModeIcon(isDark);
|
||
};
|
||
const currentTheme = localStorage.getItem(THEME_KEY) || 'dark';
|
||
applyTheme(currentTheme);
|
||
modeBtn.addEventListener('click', () => {
|
||
const current = document.body.classList.contains('cch-dark-mode') ? 'dark' : 'light';
|
||
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||
});
|
||
|
||
// View Mode
|
||
const applyView = (view) => {
|
||
const expanded = view === 'expanded';
|
||
pop.classList.toggle('cch-view-expanded', expanded);
|
||
pop.classList.toggle('cch-view-cards', !expanded);
|
||
btnCards.classList.toggle('is-selected', !expanded);
|
||
btnExpanded.classList.toggle('is-selected', expanded);
|
||
localStorage.setItem(VIEW_KEY, expanded ? 'expanded' : 'cards');
|
||
// search.focus();
|
||
};
|
||
const initialView = localStorage.getItem(VIEW_KEY) || 'cards';
|
||
applyView(initialView);
|
||
btnCards.addEventListener('click', () => applyView('cards'));
|
||
btnExpanded.addEventListener('click', () => applyView('expanded'));
|
||
|
||
// --- Navigation Logic ---
|
||
qsa('.cch-section', pop).forEach((section) => {
|
||
const grid = qs('.cch-grid', section);
|
||
if (!grid) return;
|
||
qsa(':scope > .cch-card', grid).forEach((card, idx) => {
|
||
card.dataset.cchIdx = String(idx);
|
||
qsa(':scope .cch-submenu > li', card).forEach((li, sidx) => {
|
||
li.dataset.cchIdx = String(sidx);
|
||
});
|
||
});
|
||
});
|
||
|
||
let activeCard = null;
|
||
let activeSubIndex = -1;
|
||
let keyboardMode = false;
|
||
let lastHoverCard = null;
|
||
|
||
const setKeyboardMode = (on) => {
|
||
keyboardMode = !!on;
|
||
pop.classList.toggle('cch-keynav', keyboardMode);
|
||
};
|
||
const isVisible = (el) => !!(el && el.getClientRects && el.getClientRects().length);
|
||
const candidateCards = (root) => {
|
||
const list = qsa('.cch-card', root).filter(isVisible);
|
||
if (!root.classList.contains('cch-has-query')) return list;
|
||
return list.filter((c) => !c.classList.contains('cch-dim'));
|
||
};
|
||
const candidateSubs = (card, root) => {
|
||
const subs = qsa('.cch-submenu a', card);
|
||
// If searching, only return non-dimmed (matching) sub-items
|
||
if (root.classList.contains('cch-has-query')) {
|
||
return subs.filter((a) => !a.classList.contains('cch-dim'));
|
||
}
|
||
return subs;
|
||
};
|
||
|
||
const clearSubHighlight = () => { qsa('.cch-submenu a.cch-sub-active', pop).forEach((a) => a.classList.remove('cch-sub-active')); };
|
||
|
||
const syncSubmenuOpenState = () => {
|
||
qsa('.cch-card.cch-sub-open', pop).forEach((c) =>
|
||
c.classList.remove('cch-sub-open')
|
||
);
|
||
if (activeCard && activeSubIndex >= 0) {
|
||
activeCard.classList.add('cch-sub-open');
|
||
}
|
||
};
|
||
|
||
const updateSubHighlight = () => {
|
||
clearSubHighlight();
|
||
syncSubmenuOpenState();
|
||
if (!activeCard) return;
|
||
const subs = candidateSubs(activeCard, pop);
|
||
if (!subs.length) return;
|
||
if (activeSubIndex >= 0 && subs[activeSubIndex]) {
|
||
subs[activeSubIndex].classList.add('cch-sub-active');
|
||
subs[activeSubIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
}
|
||
};
|
||
|
||
const setActiveCard = (card) => {
|
||
if (activeCard === card) return;
|
||
if (activeCard) activeCard.classList.remove('cch-active');
|
||
activeCard = card && isVisible(card) ? card : null;
|
||
activeSubIndex = -1;
|
||
clearSubHighlight();
|
||
syncSubmenuOpenState();
|
||
if (activeCard) {
|
||
activeCard.classList.add('cch-active');
|
||
activeCard.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
}
|
||
};
|
||
|
||
const clearActiveByMouse = () => {
|
||
if (activeCard) activeCard.classList.remove('cch-active');
|
||
activeCard = null;
|
||
activeSubIndex = -1;
|
||
clearSubHighlight();
|
||
syncSubmenuOpenState();
|
||
setKeyboardMode(false);
|
||
};
|
||
|
||
const firstVisibleCard = () => candidateCards(pop)[0] || null;
|
||
const allCards = () => candidateCards(pop);
|
||
|
||
const pickStartCard = () => {
|
||
if (lastHoverCard && isVisible(lastHoverCard)) return lastHoverCard;
|
||
if (activeCard && isVisible(activeCard)) return activeCard;
|
||
return firstVisibleCard();
|
||
};
|
||
|
||
/* Spatial Navigation Logic */
|
||
const measureGlobalModel = () => {
|
||
const vis = candidateCards(pop);
|
||
const entries = vis.map((el) => {
|
||
const r = el.getBoundingClientRect();
|
||
return {
|
||
el, left: r.left, top: r.top, right: r.right, bottom: r.bottom,
|
||
cx: r.left + r.width / 2, cy: r.top + r.height / 2,
|
||
};
|
||
});
|
||
if (!entries.length) return { rows: [], byCol: [] };
|
||
entries.sort((a, b) => a.top !== b.top ? a.top - b.top : a.left - b.left);
|
||
const rows = [];
|
||
const ROW_TOL = 10;
|
||
entries.forEach((e) => {
|
||
const last = rows[rows.length - 1];
|
||
if (!last) { rows.push([e]); return; }
|
||
const rowTop = last[0].top;
|
||
if (Math.abs(e.top - rowTop) <= ROW_TOL) last.push(e);
|
||
else rows.push([e]);
|
||
});
|
||
rows.forEach((row) => row.sort((a, b) => a.left - b.left));
|
||
const firstRow = rows[0] || [];
|
||
const columns = firstRow.map((e) => e.cx);
|
||
const byCol = columns.map(() => []);
|
||
entries.forEach((e) => {
|
||
if (!columns.length) return;
|
||
let best = 0; let bestd = Infinity;
|
||
columns.forEach((cx, i) => {
|
||
const d = Math.abs(e.cx - cx);
|
||
if (d < bestd) { bestd = d; best = i; }
|
||
});
|
||
byCol[best].push(e);
|
||
});
|
||
byCol.forEach((col) => col.sort((a, b) => a.top - b.top));
|
||
return { rows, byCol };
|
||
};
|
||
|
||
const moveHorizontal = (delta) => {
|
||
const start = pickStartCard();
|
||
if (!start) return;
|
||
if (!activeCard) setActiveCard(start);
|
||
const { rows } = measureGlobalModel();
|
||
if (!rows.length) return;
|
||
let rowIdx = -1; let colIdx = -1;
|
||
for (let r = 0; r < rows.length; r++) {
|
||
const c = rows[r].findIndex((e) => e.el === activeCard);
|
||
if (c !== -1) { rowIdx = r; colIdx = c; break; }
|
||
}
|
||
if (rowIdx === -1) { setActiveCard(rows[0][0].el); return; }
|
||
const row = rows[rowIdx];
|
||
let nextRowIdx = rowIdx;
|
||
let nextColIdx = colIdx + (delta > 0 ? 1 : -1);
|
||
if (nextColIdx >= row.length) {
|
||
nextRowIdx = (rowIdx + 1) % rows.length;
|
||
nextColIdx = 0;
|
||
} else if (nextColIdx < 0) {
|
||
nextRowIdx = (rowIdx - 1 + rows.length) % rows.length;
|
||
const prevRow = rows[nextRowIdx];
|
||
nextColIdx = prevRow.length - 1;
|
||
}
|
||
setActiveCard(rows[nextRowIdx][nextColIdx].el);
|
||
};
|
||
|
||
const moveVerticalGlobal = (dir) => {
|
||
const start = pickStartCard();
|
||
if (!start) return;
|
||
if (!activeCard) setActiveCard(start);
|
||
const { byCol } = measureGlobalModel();
|
||
if (!byCol.length) return;
|
||
let colIdx = -1; let rowIdx = -1;
|
||
for (let c = 0; c < byCol.length; c++) {
|
||
const r = byCol[c].findIndex((e) => e.el === activeCard);
|
||
if (r !== -1) { colIdx = c; rowIdx = r; break; }
|
||
}
|
||
if (colIdx === -1) return;
|
||
const step = (c, r, d) => {
|
||
if (d > 0) {
|
||
if (r < byCol[c].length - 1) return { c, r: r + 1 };
|
||
return { c: (c + 1) % byCol.length, r: 0 };
|
||
} else {
|
||
if (r > 0) return { c, r: r - 1 };
|
||
const pc = (c - 1 + byCol.length) % byCol.length;
|
||
return { c: pc, r: byCol[pc].length - 1 };
|
||
}
|
||
};
|
||
const next = step(colIdx, rowIdx, dir);
|
||
setActiveCard(byCol[next.c][next.r].el);
|
||
};
|
||
|
||
const moveVertical = (dir) => {
|
||
const start = pickStartCard();
|
||
if (!start) return;
|
||
if (!activeCard) setActiveCard(start);
|
||
const subs = candidateSubs(activeCard, pop);
|
||
if (subs.length) {
|
||
if (activeSubIndex === -1) {
|
||
if (dir > 0) { activeSubIndex = 0; updateSubHighlight(); return; }
|
||
moveVerticalGlobal(-1);
|
||
const newSubs = candidateSubs(activeCard, pop);
|
||
if (newSubs.length) { activeSubIndex = newSubs.length - 1; updateSubHighlight(); }
|
||
return;
|
||
}
|
||
const nextIndex = activeSubIndex + dir;
|
||
if (nextIndex >= 0 && nextIndex < subs.length) {
|
||
activeSubIndex = nextIndex; updateSubHighlight(); return;
|
||
}
|
||
if (nextIndex < 0) { activeSubIndex = -1; updateSubHighlight(); return; }
|
||
if (nextIndex >= subs.length) {
|
||
activeSubIndex = -1; updateSubHighlight(); moveVerticalGlobal(+1); return;
|
||
}
|
||
} else {
|
||
if (dir < 0) {
|
||
moveVerticalGlobal(-1);
|
||
const newSubs = candidateSubs(activeCard, pop);
|
||
if (newSubs.length) { activeSubIndex = newSubs.length - 1; updateSubHighlight(); }
|
||
} else {
|
||
moveVerticalGlobal(+1);
|
||
}
|
||
return;
|
||
}
|
||
moveVerticalGlobal(dir);
|
||
};
|
||
|
||
const clickSelection = () => {
|
||
if (!activeCard) return;
|
||
const subs = candidateSubs(activeCard, pop);
|
||
if (subs.length && activeSubIndex >= 0 && subs[activeSubIndex]) {
|
||
subs[activeSubIndex].click();
|
||
return;
|
||
}
|
||
// Click the main link inside the card
|
||
const mainLink = qs('.cch-card-main-link', activeCard);
|
||
if (mainLink) mainLink.click();
|
||
};
|
||
|
||
const ensureActiveAfterFilter = () => {
|
||
const first = firstVisibleCard();
|
||
if (!first) { setActiveCard(null); return; }
|
||
if (!activeCard || !isVisible(activeCard)) { setActiveCard(first); }
|
||
};
|
||
|
||
// --- Search Logic ---
|
||
search.addEventListener('input', () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
pop.classList.toggle('cch-has-query', q.length > 0);
|
||
|
||
// --- EASTER EGG START ---
|
||
const easterEggId = 'cch-easter-egg-card';
|
||
let eggCard = document.getElementById(easterEggId);
|
||
|
||
if (q === 'dark mode') {
|
||
if (!eggCard) {
|
||
// Create the Magic Card
|
||
const isActive = CFG.darkModeActive;
|
||
const eggItem = {
|
||
label: isActive ? 'Disable Dark Mode' : 'Enable Dark Mode',
|
||
href: '#',
|
||
icon: makeDashiconIcon('dashicons-art'), // Paintbrush icon
|
||
meta: 'Experimental Theme'
|
||
};
|
||
|
||
eggCard = buildCard(eggItem);
|
||
eggCard.id = easterEggId;
|
||
eggCard.classList.add('cch-card-easter-egg'); // For specific styling if needed
|
||
|
||
// Click Handler
|
||
eggCard.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const labelEl = eggCard.querySelector('.cch-label');
|
||
if(labelEl) labelEl.textContent = 'Toggling...';
|
||
|
||
// Toggle CSS immediately for feedback
|
||
const linkId = 'cch-dark-overrides-css';
|
||
let linkTag = document.getElementById(linkId);
|
||
|
||
if (linkTag) {
|
||
// Disable
|
||
linkTag.remove();
|
||
CFG.darkModeActive = false;
|
||
} else {
|
||
// Enable
|
||
linkTag = document.createElement('link');
|
||
linkTag.id = linkId;
|
||
linkTag.rel = 'stylesheet';
|
||
linkTag.href = CFG.darkCssUrl;
|
||
linkTag.media = 'all';
|
||
document.head.appendChild(linkTag);
|
||
CFG.darkModeActive = true;
|
||
}
|
||
|
||
// Persist via AJAX
|
||
const formData = new FormData();
|
||
formData.append('action', 'cch_toggle_dark_mode');
|
||
formData.append('nonce', CFG.nonce);
|
||
|
||
fetch(window.ajaxurl || '/wp-admin/admin-ajax.php', {
|
||
method: 'POST',
|
||
body: formData
|
||
}).then(() => {
|
||
// Update label after save
|
||
if(labelEl) labelEl.textContent = CFG.darkModeActive ? 'Disable Dark Mode' : 'Enable Dark Mode';
|
||
});
|
||
});
|
||
|
||
// Prepend to the first visible grid
|
||
const firstGrid = qs('.cch-grid', pop);
|
||
if (firstGrid) firstGrid.insertBefore(eggCard, firstGrid.firstChild);
|
||
}
|
||
} else if (q === 'refresh menu') {
|
||
if (!eggCard) {
|
||
const eggItem = {
|
||
label: 'Refresh Menu',
|
||
href: '#',
|
||
icon: makeDashiconIcon('dashicons-update'),
|
||
meta: 'Purge menu cache'
|
||
};
|
||
|
||
eggCard = buildCard(eggItem);
|
||
eggCard.id = easterEggId;
|
||
eggCard.classList.add('cch-card-easter-egg');
|
||
|
||
eggCard.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const labelEl = eggCard.querySelector('.cch-label');
|
||
if (labelEl) labelEl.textContent = 'Refreshing...';
|
||
|
||
const formData = new FormData();
|
||
formData.append('action', 'cch_purge_menu_cache');
|
||
formData.append('nonce', CFG.nonce);
|
||
|
||
fetch(window.ajaxurl || '/wp-admin/admin-ajax.php', {
|
||
method: 'POST',
|
||
body: formData
|
||
}).then(() => {
|
||
if (labelEl) labelEl.textContent = 'Done! Reloading...';
|
||
setTimeout(() => window.location.reload(), 500);
|
||
});
|
||
});
|
||
|
||
const firstGrid = qs('.cch-grid', pop);
|
||
if (firstGrid) firstGrid.insertBefore(eggCard, firstGrid.firstChild);
|
||
}
|
||
} else {
|
||
// Remove egg if search doesn't match exactly
|
||
if (eggCard) eggCard.remove();
|
||
}
|
||
// --- EASTER EGG END ---
|
||
|
||
qsa('.cch-card', pop).forEach((card) => {
|
||
const titleEl = qs('.cch-label', card);
|
||
const rawTitle = (titleEl && titleEl.dataset && titleEl.dataset.cchRaw) || (titleEl && titleEl.textContent) || '';
|
||
const titleMatch = q.length === 0 || rawTitle.toLowerCase().includes(q.toLowerCase());
|
||
highlightInto(titleEl, rawTitle, q);
|
||
|
||
const ul = qs('.cch-submenu', card);
|
||
let matchCount = 0;
|
||
if (ul) {
|
||
const lis = qsa(':scope > li', ul);
|
||
const matches = [];
|
||
const nonMatches = [];
|
||
lis.forEach((li) => {
|
||
const a = qs('a', li);
|
||
if (!a) return;
|
||
const raw = (a.dataset && a.dataset.cchRaw) || a.textContent || '';
|
||
const isMatch = q.length === 0 || raw.toLowerCase().includes(q.toLowerCase());
|
||
highlightInto(a, raw, q);
|
||
if (isMatch) { a.classList.remove('cch-dim'); matchCount++; matches.push(li); }
|
||
else { a.classList.add('cch-dim'); nonMatches.push(li); }
|
||
});
|
||
const byIdx = (a, b) => (+a.dataset.cchIdx || 0) - (+b.dataset.cchIdx || 0);
|
||
matches.sort(byIdx);
|
||
nonMatches.sort(byIdx);
|
||
[...matches, ...nonMatches].forEach((li) => ul.appendChild(li));
|
||
}
|
||
|
||
const isCardMatch = titleMatch || matchCount > 0;
|
||
card.classList.toggle('cch-dim', q.length > 0 && !isCardMatch);
|
||
card.classList.toggle('cch-sub-open', q.length > 0 && matchCount > 0 && !titleMatch);
|
||
});
|
||
|
||
// Reorder sections
|
||
qsa('.cch-section', pop).forEach((section) => {
|
||
const grid = qs('.cch-grid', section);
|
||
if (!grid) return;
|
||
const cards = qsa(':scope > .cch-card', grid);
|
||
const matches = [];
|
||
const nonMatches = [];
|
||
cards.forEach((card) => {
|
||
if (card.classList.contains('cch-dim')) nonMatches.push(card);
|
||
else matches.push(card);
|
||
});
|
||
const byIdx = (a, b) => (+a.dataset.cchIdx || 0) - (+b.dataset.cchIdx || 0);
|
||
matches.sort(byIdx);
|
||
nonMatches.sort(byIdx);
|
||
[...matches, ...nonMatches].forEach((c) => grid.appendChild(c));
|
||
const matchCount = section.querySelectorAll('.cch-card:not(.cch-dim)').length;
|
||
const hide = search.value.trim().length > 0 && matchCount === 0;
|
||
section.classList.toggle('cch-hidden', hide);
|
||
});
|
||
|
||
activeSubIndex = -1;
|
||
updateSubHighlight();
|
||
const first = allCards()[0] || null;
|
||
if (!first) setActiveCard(null);
|
||
else if (!activeCard || activeCard.classList.contains('cch-dim')) setActiveCard(first);
|
||
});
|
||
|
||
// Mouse behavior
|
||
pop.addEventListener('mouseover', (e) => {
|
||
const hovered = e.target.closest('.cch-card');
|
||
if (!hovered) return;
|
||
lastHoverCard = hovered;
|
||
if (keyboardMode && activeCard && hovered !== activeCard) {
|
||
clearActiveByMouse();
|
||
}
|
||
});
|
||
|
||
// --- Keyboard Handler ---
|
||
const keyIs = (e, names, codes) => {
|
||
const k = (e.key || '').toLowerCase();
|
||
const c = (e.code || '').toLowerCase();
|
||
const kc = e.keyCode || e.which || 0;
|
||
return (
|
||
names.some((n) => k === n.toLowerCase()) ||
|
||
names.some((n) => c === n.toLowerCase()) ||
|
||
(codes && codes.includes(kc))
|
||
);
|
||
};
|
||
|
||
// Helper to check if active card is in top row of grid
|
||
const isAtTopRow = () => {
|
||
if (!activeCard) return false;
|
||
const { rows } = measureGlobalModel();
|
||
if (!rows.length) return false;
|
||
return rows[0].some(r => r.el === activeCard);
|
||
};
|
||
|
||
const handleNavKey = (e) => {
|
||
if (pop.getAttribute('aria-hidden') === 'true') return;
|
||
if (e.defaultPrevented || e.isComposing) return;
|
||
|
||
const isSearchFocused = document.activeElement === search;
|
||
|
||
// ENTER
|
||
if (keyIs(e, ['Enter'], [13])) {
|
||
e.preventDefault();
|
||
clickSelection();
|
||
return;
|
||
}
|
||
|
||
// ARROW DOWN
|
||
if (keyIs(e, ['ArrowDown', 'Down'], [40])) {
|
||
// CASE 1: Starting navigation from Search Bar or no selection
|
||
if (isSearchFocused || !activeCard) {
|
||
e.preventDefault();
|
||
setKeyboardMode(true);
|
||
|
||
// Simple Logic: Just grab the very first visible card in the DOM
|
||
// Do not use pickStartCard() or spatial logic here.
|
||
const first = firstVisibleCard();
|
||
|
||
if (first) {
|
||
search.blur();
|
||
setActiveCard(first);
|
||
|
||
// If the first card has search matches inside (pills),
|
||
// automatically select the first matching pill.
|
||
const subs = candidateSubs(first, pop);
|
||
if (subs.length > 0) {
|
||
activeSubIndex = 0;
|
||
updateSubHighlight();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// CASE 2: Already navigating the grid
|
||
e.preventDefault();
|
||
setKeyboardMode(true);
|
||
|
||
// If we are on a card, check if we should enter its sub-menu
|
||
const subs = candidateSubs(activeCard, pop);
|
||
if (subs.length && activeSubIndex === -1) {
|
||
activeSubIndex = 0; updateSubHighlight();
|
||
} else {
|
||
// Otherwise move to the card below visually
|
||
moveVertical(+1);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ARROW UP
|
||
if (keyIs(e, ['ArrowUp', 'Up'], [38])) {
|
||
if (isSearchFocused) return; // Allow normal cursor movement
|
||
|
||
e.preventDefault();
|
||
setKeyboardMode(true);
|
||
|
||
// If inside submenu, move up
|
||
if (activeSubIndex > 0) {
|
||
activeSubIndex--; updateSubHighlight();
|
||
return;
|
||
}
|
||
// If at top of submenu, return to Card
|
||
if (activeSubIndex === 0) {
|
||
activeSubIndex = -1; updateSubHighlight();
|
||
return;
|
||
}
|
||
// If at Card, check if we should jump to Search
|
||
if (isAtTopRow()) {
|
||
clearActiveByMouse();
|
||
search.focus();
|
||
search.select(); // Select text so typing replaces it
|
||
return;
|
||
}
|
||
|
||
// Otherwise move card up
|
||
moveVertical(-1);
|
||
return;
|
||
}
|
||
|
||
// ARROW LEFT / RIGHT
|
||
if (keyIs(e, ['ArrowLeft', 'Left', 'ArrowRight', 'Right'], [37, 39])) {
|
||
if (isSearchFocused) return; // Allow text nav
|
||
|
||
e.preventDefault();
|
||
setKeyboardMode(true);
|
||
activeSubIndex = -1;
|
||
updateSubHighlight();
|
||
const dir = keyIs(e, ['ArrowRight', 'Right'], [39]) ? 1 : -1;
|
||
moveHorizontal(dir);
|
||
return;
|
||
}
|
||
|
||
// TYPE TO SEARCH (Auto-focus)
|
||
// If user types a letter while grid is focused, jump to search
|
||
if (!isSearchFocused && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
search.focus();
|
||
// do not prevent default, allow char to be typed
|
||
}
|
||
};
|
||
document.addEventListener('keydown', handleNavKey);
|
||
|
||
return { pop, show, hide };
|
||
};
|
||
|
||
const init = () => {
|
||
// 1. Detect if we are inside the Site Editor (FSE) Iframe
|
||
// If so, remove this instance of the island to prevent double loading.
|
||
try {
|
||
if (window.self !== window.top && window.parent.location.href.includes('site-editor.php')) {
|
||
const island = document.getElementById('cch-island-container');
|
||
if (island) island.remove();
|
||
|
||
// Also remove the admin bar shim if it exists to prevent spacing issues
|
||
const html = document.documentElement;
|
||
if (html) html.style.marginTop = '0px';
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
// Cross-origin errors or other issues; ignore.
|
||
}
|
||
|
||
document.body.classList.add('cch-hide-admin-menu');
|
||
|
||
let pop, show, hide;
|
||
try {
|
||
const result = buildPopout();
|
||
pop = result.pop;
|
||
show = result.show;
|
||
hide = result.hide;
|
||
} catch (e) {
|
||
console.error('CaptainCore Helm: Error building popout', e);
|
||
return;
|
||
}
|
||
|
||
// Attach listeners to context menu if it exists (rendered by PHP)
|
||
const contextMenu = document.getElementById('cch-context-menu');
|
||
if (contextMenu) {
|
||
const label = contextMenu.querySelector('.cch-context-label');
|
||
const links = Array.from(contextMenu.querySelectorAll('.cch-context-item'));
|
||
let focusIndex = -1;
|
||
|
||
const updateFocus = () => {
|
||
links.forEach((link, idx) => {
|
||
if (idx === focusIndex) {
|
||
link.classList.add('cch-focus');
|
||
link.scrollIntoView({ block: 'nearest' });
|
||
} else {
|
||
link.classList.remove('cch-focus');
|
||
}
|
||
});
|
||
};
|
||
|
||
const resetFocus = () => {
|
||
focusIndex = -1;
|
||
updateFocus();
|
||
};
|
||
|
||
const closeContextMenu = () => {
|
||
contextMenu.classList.remove('cch-context-open');
|
||
resetFocus();
|
||
};
|
||
|
||
if (label) {
|
||
label.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const isOpen = contextMenu.classList.contains('cch-context-open');
|
||
if (isOpen) {
|
||
closeContextMenu();
|
||
} else {
|
||
contextMenu.classList.add('cch-context-open');
|
||
}
|
||
});
|
||
}
|
||
|
||
links.forEach(link => {
|
||
// Mouse interaction should clear keyboard focus to prevent confusion
|
||
link.addEventListener('mouseenter', () => {
|
||
focusIndex = -1;
|
||
updateFocus();
|
||
});
|
||
|
||
link.addEventListener('click', (e) => {
|
||
closeContextMenu();
|
||
handleLinkClick(e);
|
||
});
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!contextMenu.contains(e.target)) {
|
||
closeContextMenu();
|
||
}
|
||
});
|
||
|
||
// Keyboard Navigation for Context Menu
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!contextMenu.classList.contains('cch-context-open')) return;
|
||
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
closeContextMenu();
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
focusIndex++;
|
||
if (focusIndex >= links.length) focusIndex = 0;
|
||
updateFocus();
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (focusIndex === -1) focusIndex = links.length;
|
||
focusIndex--;
|
||
if (focusIndex < 0) focusIndex = links.length - 1;
|
||
updateFocus();
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Enter') {
|
||
// If an item is focused via keyboard
|
||
if (focusIndex > -1 && links[focusIndex]) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
links[focusIndex].click();
|
||
return;
|
||
}
|
||
|
||
// If focus is on the label itself (e.g. user tabbed to it)
|
||
// we allow default behavior or toggle logic, but if menu is open
|
||
// and no item selected, we might want to close it or do nothing.
|
||
}
|
||
});
|
||
}
|
||
|
||
const islandContainer = document.getElementById('cch-island-container');
|
||
const islandToggle = document.getElementById('cch-island-toggle');
|
||
const hideBtn = document.getElementById('cch-ui-hide-btn');
|
||
const toast = document.getElementById('cch-ui-toast');
|
||
|
||
// --- Hide Logic ---
|
||
if (hideBtn && islandContainer) {
|
||
hideBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// Close context menu if open
|
||
if (contextMenu) contextMenu.classList.remove('cch-context-open');
|
||
|
||
// Hide Island (non-persistent, reloading restores)
|
||
islandContainer.classList.add('cch-ui-hidden');
|
||
|
||
// Show Toast
|
||
if (toast) {
|
||
toast.classList.add('cch-show');
|
||
setTimeout(() => {
|
||
toast.classList.remove('cch-show');
|
||
}, 4000);
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- Main Toggle Logic ---
|
||
if (islandToggle) {
|
||
islandToggle.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
if(islandToggle.classList.contains('cch-loading')) return;
|
||
const isOpen = pop.getAttribute('aria-hidden') === 'false';
|
||
if (isOpen) hide();
|
||
else show();
|
||
});
|
||
}
|
||
|
||
// Handle Edit/View action buttons
|
||
const actionButtons = document.querySelectorAll('.cch-island-action');
|
||
actionButtons.forEach((btn) => {
|
||
btn.addEventListener('click', (e) => {
|
||
const href = btn.getAttribute('href');
|
||
if (href && href !== '#') {
|
||
e.preventDefault();
|
||
triggerLoading(href);
|
||
window.location.href = href;
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- Query Monitor Integration ---
|
||
const qmBtn = document.getElementById('cch-island-qm');
|
||
if (qmBtn) {
|
||
// 1. Toggle Logic
|
||
qmBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// QM attaches click listeners to the admin bar link.
|
||
// We simulate a click on the hidden admin bar link if possible,
|
||
// or toggle the class directly if we know the ID.
|
||
const qmBarLink = document.querySelector('#wp-admin-bar-query-monitor > a');
|
||
const qmPanel = document.getElementById('query-monitor-main');
|
||
|
||
if (qmPanel) {
|
||
if (qmPanel.classList.contains('qm-show')) {
|
||
qmPanel.classList.remove('qm-show');
|
||
document.body.classList.remove('qm-show'); // QM sometimes adds this
|
||
} else {
|
||
qmPanel.classList.add('qm-show');
|
||
// Ensure it has height
|
||
if (qmPanel.offsetHeight < 50) qmPanel.style.height = '400px';
|
||
}
|
||
} else if (qmBarLink) {
|
||
qmBarLink.click();
|
||
}
|
||
});
|
||
|
||
// 2. Status Sync (Color)
|
||
// Query Monitor applies classes like .qm-error, .qm-warning to the admin bar item.
|
||
// We want to copy those to our Helm button.
|
||
const syncQmStatus = () => {
|
||
const qmBarItem = document.getElementById('wp-admin-bar-query-monitor');
|
||
if (!qmBarItem) return;
|
||
|
||
const classes = ['qm-error', 'qm-warning', 'qm-alert'];
|
||
classes.forEach(cls => {
|
||
if (qmBarItem.classList.contains(cls)) {
|
||
qmBtn.classList.add(cls);
|
||
} else {
|
||
qmBtn.classList.remove(cls);
|
||
}
|
||
});
|
||
};
|
||
|
||
// Run on load
|
||
syncQmStatus();
|
||
|
||
// Observe changes (QM updates via AJAX sometimes)
|
||
const qmBarItem = document.getElementById('wp-admin-bar-query-monitor');
|
||
if (qmBarItem) {
|
||
const observer = new MutationObserver(syncQmStatus);
|
||
observer.observe(qmBarItem, { attributes: true, attributeFilter: ['class'] });
|
||
}
|
||
}
|
||
|
||
// --- Keyboard Logic ---
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!isOpenShortcut(e)) return;
|
||
e.preventDefault();
|
||
|
||
// Special Case: If UI is hidden, restore it first.
|
||
if (islandContainer && islandContainer.classList.contains('cch-ui-hidden')) {
|
||
islandContainer.classList.remove('cch-ui-hidden');
|
||
// Proceed to open the menu for immediate feedback.
|
||
}
|
||
|
||
const isOpen = pop.getAttribute('aria-hidden') === 'false';
|
||
if (isOpen) hide();
|
||
else show();
|
||
});
|
||
|
||
// Global link click listener for loading state
|
||
document.addEventListener('click', (e) => {
|
||
const a = e.target.closest('a');
|
||
if (!a || !a.href || e.defaultPrevented) return;
|
||
|
||
// Ignore if inside popout (handled specifically)
|
||
if (pop.contains(a)) return;
|
||
|
||
// Ignore modifiers
|
||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||
if (a.target === '_blank') return;
|
||
|
||
// Ignore anchors, js, etc
|
||
const href = a.getAttribute('href');
|
||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||
if (a.classList.contains('thickbox') || a.classList.contains('button-disabled') || a.hasAttribute('download')) return;
|
||
|
||
// Check for same-page hash navigation
|
||
try {
|
||
const url = new URL(a.href, window.location.origin);
|
||
if (url.origin === window.location.origin &&
|
||
url.pathname === window.location.pathname &&
|
||
url.search === window.location.search &&
|
||
url.hash) {
|
||
return;
|
||
}
|
||
} catch (err) { /* ignore invalid URLs */ }
|
||
|
||
// Trigger visual loading state
|
||
triggerLoading(a.href);
|
||
});
|
||
|
||
// Clear loading state when navigating back (bfcache)
|
||
window.addEventListener('pageshow', (e) => {
|
||
if (e.persisted) {
|
||
const toggle = document.getElementById('cch-island-toggle');
|
||
if (toggle) {
|
||
toggle.classList.remove('cch-loading');
|
||
const label = toggle.querySelector('.cch-island-label');
|
||
const icon = toggle.querySelector('.cch-island-icon');
|
||
const loader = toggle.querySelector('.cch-island-loader');
|
||
if (label) label.textContent = 'Menu';
|
||
if (icon) icon.style.display = '';
|
||
if (loader) loader.style.display = 'none';
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||
else init();
|
||
})(); |