Files
hyprcrd/public/js/hyprcrd.js
T
2026-04-10 11:46:15 +02:00

241 lines
9.6 KiB
JavaScript

// ── hyprcrd Plugin API ──
//
// Loaded synchronously BEFORE any module script. Plugins and core both
// register against window.hyprcrd. Plugins MUST NOT import from core
// modules — use this global API instead so they remain decoupled.
//
// Available namespaces:
// hyprcrd.hooks — generic event bus (on/emit)
// hyprcrd.editor — toolbar command registry
// hyprcrd.markdown — fenced block + post-processing registry
// hyprcrd.util — DOM helpers exposed for plugin use
//
// Plugins are loaded as <script type="module"> tags after this script
// but before app.js. They self-register on module evaluation; the core
// then queries the registries when needed.
(function () {
'use strict';
// ── Generic event bus ───────────────────────────────────────────
const hookListeners = Object.create(null);
const hooks = {
on(name, fn) {
if (typeof fn !== 'function') return;
(hookListeners[name] ||= []).push(fn);
},
off(name, fn) {
const arr = hookListeners[name];
if (!arr) return;
const idx = arr.indexOf(fn);
if (idx >= 0) arr.splice(idx, 1);
},
emit(name, data) {
const arr = hookListeners[name];
if (!arr) return data;
for (const fn of arr.slice()) {
try {
const r = fn(data);
if (r !== undefined) data = r;
} catch (err) {
console.error(`[hyprcrd hook ${name}]`, err);
}
}
return data;
},
};
// ── Editor command registry ─────────────────────────────────────
// Toolbar buttons with `data-md="<name>"` dispatch to handlers
// registered here. Plugins inject buttons + register handlers.
const editorCommands = Object.create(null);
const editor = {
registerCommand(name, fn) {
if (typeof fn !== 'function') return;
editorCommands[name] = fn;
},
unregisterCommand(name) {
delete editorCommands[name];
},
getCommand(name) {
return editorCommands[name];
},
// Set by editor.js after init — gives plugins access to
// insertMd(before, after, placeholder) and the textarea body
// without forcing an import dependency.
api: null,
};
// ── Markdown fenced block registry ──────────────────────────────
// Each entry: { lang: string, handler: (content) => string }
// The handler receives the raw text inside ```<lang> ... ``` and
// must return safe HTML (it will pass through DOMPurify after).
const fencedBlocks = [];
// Pre/post processors run on the full source text, before/after
// marked.parse(). Useful for non-fenced syntax extensions.
const preProcessors = [];
const postProcessors = [];
const markdown = {
registerFencedBlock(lang, handler) {
if (typeof handler !== 'function') return;
fencedBlocks.push({ lang, handler });
},
getFencedBlocks() {
return fencedBlocks.slice();
},
registerPreProcessor(fn) {
if (typeof fn === 'function') preProcessors.push(fn);
},
getPreProcessors() {
return preProcessors.slice();
},
registerPostProcessor(fn) {
if (typeof fn === 'function') postProcessors.push(fn);
},
getPostProcessors() {
return postProcessors.slice();
},
// Filled in by core markdown.js after load
render: null,
};
// ── Util helpers (mirror of internal util.js) ───────────────────
let csrfToken = '';
const util = {
$(idOrSel) {
if (typeof idOrSel !== 'string') return null;
if (idOrSel.startsWith('#') || idOrSel.includes(' ') || idOrSel.includes('.') || idOrSel.includes('[')) {
return document.querySelector(idOrSel);
}
return document.getElementById(idOrSel);
},
$$(sel) {
return Array.from(document.querySelectorAll(sel));
},
esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
},
// CSRF: core calls setCsrf() after auth so plugins can read it
setCsrf(token) { csrfToken = String(token || ''); },
csrf() { return csrfToken; },
};
/** Generic JSON API call with automatic CSRF header. */
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
const res = await fetch(path, {
method: opts.method || 'GET',
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
return res.json();
}
// ── Settings registry ───────────────────────────────────────────
// Plugins can inject their own tabs into the main settings overlay.
// The plugin's onShow callback is called whenever the tab becomes visible.
const settings = {
registerTab({ id, label, icon, onShow }) {
if (!id) return;
const nav = document.getElementById('settings-nav');
const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5');
if (!nav || !panelsContainer) {
// DOM not ready — defer to next tick
requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow }));
return;
}
if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered
// Create tab button
const btn = document.createElement('button');
btn.className = 'settings-tab';
btn.dataset.tab = id;
const iconClass = icon || 'ph-puzzle-piece';
btn.innerHTML = `<i class="ph ${iconClass} text-sm"></i><span class="font-pixel text-[10px]">${label || id}</span>`;
nav.appendChild(btn);
// Create empty panel container
let panel = document.getElementById(`settings-panel-${id}`);
if (!panel) {
panel = document.createElement('div');
panel.id = `settings-panel-${id}`;
panel.className = 'hidden';
panelsContainer.appendChild(panel);
}
// Click handler — duplicates core tab logic (theme.js may also
// attach its own handler later, but identical work is harmless).
btn.addEventListener('click', () => {
nav.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
panel.classList.remove('hidden');
if (typeof onShow === 'function') {
try { onShow(panel); } catch (err) { console.error(`[settings tab ${id}]`, err); }
}
hooks.emit('settings.tab.shown', { tab: id, panel });
});
},
/** Open the main settings overlay. */
open() {
document.getElementById('settings-btn')?.click();
},
/** Open the settings overlay and switch to a specific tab. */
openTab(id) {
this.open();
// Settings opens via click; the tab might need a frame to render
requestAnimationFrame(() => {
document.querySelector(`#settings-nav [data-tab="${id}"]`)?.click();
});
},
};
// ── Dialog API (filled in by core dialog.js after load) ─────────
// Plugins call hyprcrd.dialog.confirm(...) / hyprcrd.dialog.show(...)
// instead of native browser confirm()/prompt() so the UI stays themed
// and consistent.
const dialog = {
show: null, // (opts) => Promise<value|null> — set by core dialog.js
/** Convenience confirm wrapper. Returns Promise<true|null>. */
async confirm(message, opts = {}) {
if (typeof dialog.show !== 'function') {
// Core dialog not loaded yet — fall back to native
return window.confirm(message) ? true : null;
}
return dialog.show({
title: opts.title || 'confirm',
message,
input: false,
danger: opts.danger ?? true,
okLabel: opts.okLabel || 'ok',
cancelLabel: opts.cancelLabel || 'cancel',
});
},
};
// ── Plugin metadata registry ────────────────────────────────────
// Plugins can register themselves here for introspection / debug.
const plugins = [];
const registerPlugin = (meta) => {
if (meta && meta.name) plugins.push(meta);
};
window.hyprcrd = {
hooks,
editor,
markdown,
util,
api,
settings,
dialog,
plugins,
registerPlugin,
version: '1.0',
};
})();