mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-26 13:31:56 +07:00
feat: Implement a core plugin system, integrate flag icon assets, and establish a GitHub release workflow.
This commit is contained in:
@@ -5,10 +5,10 @@ class SiteConfig {
|
||||
const APP_NAME = 'MIVO';
|
||||
const APP_VERSION = 'v1.1.0';
|
||||
const APP_FULL_NAME = 'MIVO - Mikrotik Voucher';
|
||||
const CREDIT_NAME = 'DyzulkDev';
|
||||
const CREDIT_URL = 'https://dyzulk.com';
|
||||
const CREDIT_NAME = 'MivoDev';
|
||||
const CREDIT_URL = 'https://github.com/mivodev';
|
||||
const YEAR = '2026';
|
||||
const REPO_URL = 'https://github.com/dyzulk/mivo';
|
||||
const REPO_URL = 'https://github.com/mivodev/mivo';
|
||||
|
||||
// Security Keys
|
||||
// Fetched from .env or fallback to default
|
||||
|
||||
120
app/Core/Hooks.php
Normal file
120
app/Core/Hooks.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Hooks
|
||||
{
|
||||
/**
|
||||
* @var array Stores all registered actions
|
||||
*/
|
||||
private static $actions = [];
|
||||
|
||||
/**
|
||||
* @var array Stores all registered filters
|
||||
*/
|
||||
private static $filters = [];
|
||||
|
||||
/**
|
||||
* Register a new action
|
||||
*
|
||||
* @param string $tag The name of the action hook
|
||||
* @param callable $callback The function to call
|
||||
* @param int $priority Lower numbers correspond to earlier execution
|
||||
* @param int $accepted_args The number of arguments the function accepts
|
||||
*/
|
||||
public static function addAction($tag, $callback, $priority = 10, $accepted_args = 1)
|
||||
{
|
||||
self::$actions[$tag][$priority][] = [
|
||||
'function' => $callback,
|
||||
'accepted_args' => $accepted_args
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action
|
||||
*
|
||||
* @param string $tag The name of the action hook
|
||||
* @param mixed ...$args Optional arguments to pass to the callback
|
||||
*/
|
||||
public static function doAction($tag, ...$args)
|
||||
{
|
||||
if (empty(self::$actions[$tag])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
ksort(self::$actions[$tag]);
|
||||
|
||||
foreach (self::$actions[$tag] as $priority => $callbacks) {
|
||||
foreach ($callbacks as $callbackData) {
|
||||
call_user_func_array($callbackData['function'], array_slice($args, 0, $callbackData['accepted_args']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new filter
|
||||
*
|
||||
* @param string $tag The name of the filter hook
|
||||
* @param callable $callback The function to call
|
||||
* @param int $priority Lower numbers correspond to earlier execution
|
||||
* @param int $accepted_args The number of arguments the function accepts
|
||||
*/
|
||||
public static function addFilter($tag, $callback, $priority = 10, $accepted_args = 1)
|
||||
{
|
||||
self::$filters[$tag][$priority][] = [
|
||||
'function' => $callback,
|
||||
'accepted_args' => $accepted_args
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to a value
|
||||
*
|
||||
* @param string $tag The name of the filter hook
|
||||
* @param mixed $value The value to be filtered
|
||||
* @param mixed ...$args Optional extra arguments
|
||||
* @return mixed The filtered value
|
||||
*/
|
||||
public static function applyFilters($tag, $value, ...$args)
|
||||
{
|
||||
if (empty(self::$filters[$tag])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
ksort(self::$filters[$tag]);
|
||||
|
||||
foreach (self::$filters[$tag] as $priority => $callbacks) {
|
||||
foreach ($callbacks as $callbackData) {
|
||||
// Prepend value to args
|
||||
$params = array_merge([$value], array_slice($args, 0, $callbackData['accepted_args'] - 1));
|
||||
$value = call_user_func_array($callbackData['function'], $params);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any action has been registered for a hook.
|
||||
*
|
||||
* @param string $tag The name of the action hook.
|
||||
* @return bool True if action exists, false otherwise.
|
||||
*/
|
||||
public static function hasAction($tag)
|
||||
{
|
||||
return isset(self::$actions[$tag]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any filter has been registered for a hook.
|
||||
*
|
||||
* @param string $tag The name of the filter hook.
|
||||
* @return bool True if filter exists, false otherwise.
|
||||
*/
|
||||
public static function hasFilter($tag)
|
||||
{
|
||||
return isset(self::$filters[$tag]);
|
||||
}
|
||||
}
|
||||
78
app/Core/PluginManager.php
Normal file
78
app/Core/PluginManager.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class PluginManager
|
||||
{
|
||||
/**
|
||||
* @var string Path to plugins directory
|
||||
*/
|
||||
private $pluginsDir;
|
||||
|
||||
/**
|
||||
* @var array List of active plugins
|
||||
*/
|
||||
private $activePlugins = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pluginsDir = dirname(__DIR__, 2) . '/plugins'; // Root/plugins
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all active plugins
|
||||
*/
|
||||
public function loadPlugins()
|
||||
{
|
||||
// Ensure plugins directory exists
|
||||
if (!is_dir($this->pluginsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Get List of Active Plugins (For now, we load ALL folders as active)
|
||||
// TODO: Implement database/config check for active status
|
||||
$plugins = scandir($this->pluginsDir);
|
||||
|
||||
foreach ($plugins as $pluginName) {
|
||||
if ($pluginName === '.' || $pluginName === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pluginPath = $this->pluginsDir . '/' . $pluginName;
|
||||
|
||||
// Check if it is a directory and has specific plugin file
|
||||
if (is_dir($pluginPath) && file_exists($pluginPath . '/plugin.php')) {
|
||||
$this->loadPlugin($pluginName, $pluginPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire 'plugins_loaded' action after all plugins are loaded
|
||||
Hooks::doAction('plugins_loaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single plugin
|
||||
*
|
||||
* @param string $name Plugin folder name
|
||||
* @param string $path Full path to plugin directory
|
||||
*/
|
||||
private function loadPlugin($name, $path)
|
||||
{
|
||||
try {
|
||||
require_once $path . '/plugin.php';
|
||||
$this->activePlugins[] = $name;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to load plugin [$name]: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of loaded plugins
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getActivePlugins()
|
||||
{
|
||||
return $this->activePlugins;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,9 @@ class Router {
|
||||
}
|
||||
|
||||
public function dispatch($uri, $method) {
|
||||
// Fire hook to allow plugins to register routes
|
||||
\App\Core\Hooks::doAction('router_init', $this);
|
||||
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
|
||||
// Handle subdirectory
|
||||
|
||||
@@ -51,8 +51,8 @@ class TemplateHelper {
|
||||
|
||||
$content = str_replace(array_keys($dummyData), array_values($dummyData), $content);
|
||||
|
||||
// QR Code replacement
|
||||
$content = preg_replace('/\{\{\s*qrcode.*?\}\}/i', '<img src="https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=http://hotspot.lan/login?user=u-5829" style="width:80px;height:80px;display:inline-block;">', $content);
|
||||
// QR Code replacement - Using canvas for client-side rendering with QRious
|
||||
$content = preg_replace('/\{\{\s*qrcode\s*(.*?)\s*\}\}/i', '<canvas class="qrcode-placeholder" data-options=\'$1\' style="width:80px;height:80px;display:inline-block;"></canvas>', $content);
|
||||
|
||||
return $content;
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class TemplateHelper {
|
||||
body { display: flex; align-items: center; justify-content: center; background-color: transparent; }
|
||||
#wrapper { display: inline-block; transform-origin: center center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
</style>
|
||||
<script src="/assets/js/qrious.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrapper">' . $mockContent . '</div>
|
||||
@@ -76,6 +77,48 @@ class TemplateHelper {
|
||||
window.addEventListener("load", () => {
|
||||
const wrap = document.getElementById("wrapper");
|
||||
if(!wrap) return;
|
||||
|
||||
// Render QR Codes
|
||||
document.querySelectorAll(".qrcode-placeholder").forEach(canvas => {
|
||||
const optionsStr = canvas.dataset.options || "";
|
||||
const options = {};
|
||||
|
||||
// Robust parser for "fg=red bg=#fff size=100" format
|
||||
const regex = /([a-z]+)=([^ \t\r\n\f\v"]+|"[^"]*"|\'[^\']*\')/gi;
|
||||
let match;
|
||||
while ((match = regex.exec(optionsStr)) !== null) {
|
||||
let key = match[1].toLowerCase();
|
||||
let val = match[2].replace(/["\']/g, "");
|
||||
options[key] = val;
|
||||
}
|
||||
|
||||
new QRious({
|
||||
element: canvas,
|
||||
value: "http://hotspot.lan/login?username=u-5829&password=5912",
|
||||
size: parseInt(options.size) || 100,
|
||||
foreground: options.fg || "#000000",
|
||||
backgroundAlpha: 0,
|
||||
level: "M"
|
||||
});
|
||||
|
||||
// Handle styles via CSS for better compatibility with rounding and padding
|
||||
if (options.size) {
|
||||
canvas.style.width = options.size + "px";
|
||||
canvas.style.height = options.size + "px";
|
||||
}
|
||||
|
||||
if (options.bg) {
|
||||
canvas.style.backgroundColor = options.bg;
|
||||
}
|
||||
|
||||
if (options.padding) {
|
||||
canvas.style.padding = options.padding + "px";
|
||||
}
|
||||
|
||||
if (options.rounded) {
|
||||
canvas.style.borderRadius = options.rounded + "px";
|
||||
}
|
||||
});
|
||||
|
||||
const updateScale = () => {
|
||||
const w = wrap.offsetWidth;
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
<footer class="border-t border-accents-2 bg-background mt-auto transition-colors duration-200 py-8 text-center space-y-4">
|
||||
<!-- Links Row -->
|
||||
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
|
||||
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<a href="https://mivodev.github.io" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<a href="https://github.com/mivodev/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||
<span>Community</span>
|
||||
</a>
|
||||
<a href="https://github.com/dyzulk/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<a href="https://github.com/mivodev/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="github" class="w-4 h-4"></i>
|
||||
<span>Repo</span>
|
||||
</a>
|
||||
@@ -306,5 +306,6 @@
|
||||
}, 300); // 300ms delay to prevent accidental closure
|
||||
}
|
||||
</script>
|
||||
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<footer class="mt-auto py-8 text-center space-y-4">
|
||||
<div class="flex justify-center items-center gap-6 text-sm font-medium text-accents-5">
|
||||
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<a href="https://mivodev.github.io" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<a href="https://github.com/mivodev/mivo/issues" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||
<span>Community</span>
|
||||
</a>
|
||||
<a href="https://github.com/dyzulk/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<a href="https://github.com/mivodev/mivo" target="_blank" class="hover:text-foreground transition-colors flex items-center gap-2">
|
||||
<i data-lucide="github" class="w-4 h-4"></i>
|
||||
<span>Repo</span>
|
||||
</a>
|
||||
@@ -79,5 +79,6 @@
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
<?php \App\Core\Hooks::doAction('mivo_footer'); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,8 +19,8 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
|
||||
<!-- Tailwind CSS (Local) -->
|
||||
<link rel="stylesheet" href="/assets/css/styles.css">
|
||||
|
||||
<!-- Flag Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" />
|
||||
<!-- Flag Icons (Local) -->
|
||||
<link rel="stylesheet" href="/assets/vendor/flag-icons/css/flag-icons.min.css" />
|
||||
|
||||
|
||||
<style>
|
||||
@@ -114,6 +114,8 @@ $title = isset($title) ? $title : \App\Config\SiteConfig::APP_NAME;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<?php \App\Core\Hooks::doAction('mivo_head'); ?>
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen bg-background text-foreground anti-aliased relative">
|
||||
<!-- Background Elements (Global Sci-Fi Grid) -->
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
<?php \App\Core\Hooks::doAction('mivo_head'); ?>
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased min-h-screen relative overflow-hidden font-sans selection:bg-accents-2 selection:text-foreground flex flex-col">
|
||||
|
||||
|
||||
@@ -390,21 +390,21 @@ $getInitials = function($name) {
|
||||
</div>
|
||||
|
||||
<!-- Docs -->
|
||||
<a href="https://docs.mivo.dyzulk.com" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<a href="https://mivodev.github.io" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<i data-lucide="book-open" class="w-4 h-4"></i>
|
||||
<span data-i18n="sidebar.docs">Documentation</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||
</a>
|
||||
|
||||
<!-- Community -->
|
||||
<a href="https://github.com/dyzulk/mivo/issues" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<a href="https://github.com/mivodev/mivo/issues" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<i data-lucide="message-circle" class="w-4 h-4"></i>
|
||||
<span data-i18n="sidebar.community">Community</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||
</a>
|
||||
|
||||
<!-- Repo -->
|
||||
<a href="https://github.com/dyzulk/mivo" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<a href="https://github.com/mivodev/mivo" target="_blank" class="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-accents-6 hover:text-foreground hover:bg-white/5">
|
||||
<i data-lucide="github" class="w-4 h-4"></i>
|
||||
<span data-i18n="sidebar.repo">Repository</span>
|
||||
<i data-lucide="external-link" class="w-3 h-3 ml-auto opacity-50"></i>
|
||||
|
||||
@@ -12,6 +12,13 @@ $initialContent = $template['content'] ?? '<div style="border: 1px solid #000; p
|
||||
require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
?>
|
||||
|
||||
<style>
|
||||
/* Make CodeMirror fill the entire container height */
|
||||
#editorContainer .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="flex flex-col lg:h-[calc(100vh-8rem)] gap-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4 flex-shrink-0">
|
||||
@@ -64,7 +71,9 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="codeEditor" name="content" form="templateForm" class="form-control flex-1 w-full font-mono text-sm resize-none h-[500px]" spellcheck="false"><?= htmlspecialchars($initialContent) ?></textarea>
|
||||
<div id="editorContainer" class="flex-1 w-full font-mono text-sm h-full min-h-0 border-none outline-none overflow-hidden bg-background"></div>
|
||||
<!-- Hidden textarea for form submission -->
|
||||
<textarea id="codeEditor" name="content" form="templateForm" class="hidden"><?= htmlspecialchars($initialContent) ?></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Right: Preview -->
|
||||
@@ -84,6 +93,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/qrious.min.js"></script>
|
||||
<script src="/assets/js/vendor/editor.bundle.js"></script>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Modal -->
|
||||
@@ -103,6 +113,18 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
<div class="p-6 overflow-y-auto custom-scrollbar">
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p class="text-sm text-accents-5 mb-4" data-i18n="settings.variables_desc">Use these variables in your HTML source. They will be replaced with actual user data during printing.</p>
|
||||
|
||||
<!-- NEW: Editor Shortcuts & Emmet -->
|
||||
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2" data-i18n="settings.editor_shortcuts">Editor Shortcuts & Emmet</h3>
|
||||
<div class="p-4 rounded bg-accents-1 border border-accents-2 mb-6">
|
||||
<p class="text-sm text-accents-6 mb-4" data-i18n="settings.emmet_desc">Use Emmet abbreviations for fast coding. Look for the dotted underline, then press Tab.</p>
|
||||
<ul class="text-sm space-y-4 list-disc list-inside text-accents-6">
|
||||
<li data-i18n="settings.tip_emmet_html"><strong>HTML Boilerplate</strong>: Type <code>!</code> then <code>Tab</code>.</li>
|
||||
<li data-i18n="settings.tip_emmet_tag"><strong>Auto-Tag</strong>: Type <code>.container</code> then <code>Tab</code> for <code><div class="container"></code>.</li>
|
||||
<li data-i18n="settings.tip_color_picker"><strong>Color Picker</strong>: Click the color box next to hex codes (e.g., #ff0000) to open the picker.</li>
|
||||
<li data-i18n="settings.tip_syntax_error"><strong>Syntax Error</strong>: Red squiggles (and dots in the gutter) show structure errors like mismatched tags.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-bold uppercase text-accents-5 mb-2">Basic Variables</h3>
|
||||
<div class="grid grid-cols-1 gap-2 mb-6">
|
||||
@@ -201,148 +223,43 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editor Logic ---
|
||||
const editor = document.getElementById('codeEditor');
|
||||
// --- Editor Logic (CodeMirror 6) ---
|
||||
const textarea = document.getElementById('codeEditor');
|
||||
const container = document.getElementById('editorContainer');
|
||||
const preview = document.getElementById('previewContainer');
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
// History Stack for Undo/Redo
|
||||
let historyStack = [];
|
||||
let redoStack = [];
|
||||
let isTyping = false;
|
||||
let typingTimer = null;
|
||||
|
||||
// Initial State
|
||||
historyStack.push({ value: editor.value, selectionStart: 0, selectionEnd: 0 });
|
||||
let cmView = null;
|
||||
|
||||
function saveState() {
|
||||
// Limit stack size
|
||||
if (historyStack.length > 50) historyStack.shift();
|
||||
|
||||
const lastState = historyStack[historyStack.length - 1];
|
||||
if (lastState && lastState.value === editor.value) return; // No change
|
||||
|
||||
historyStack.push({
|
||||
value: editor.value,
|
||||
selectionStart: editor.selectionStart,
|
||||
selectionEnd: editor.selectionEnd
|
||||
});
|
||||
redoStack = []; // Clear redo on new change
|
||||
}
|
||||
|
||||
// Debounced save for typing
|
||||
editor.addEventListener('input', (e) => {
|
||||
if (!isTyping) {
|
||||
// Save state *before* a burst of typing starts?
|
||||
// Actually usually we save *after*.
|
||||
// For robust undo: save state Before modification if possible, or assume previous state is safe.
|
||||
// Simplified: Save debounced.
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(saveState, 500);
|
||||
}
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// --- Keyboard Shortcuts (Undo/Redo, Tab, Enter) ---
|
||||
editor.addEventListener('keydown', function(e) {
|
||||
// Undo: Ctrl+Z
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
// Redo: Ctrl+Y or Ctrl+Shift+Z
|
||||
if (((e.ctrlKey || e.metaKey) && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
function initEditor() {
|
||||
if (typeof MivoEditor === 'undefined') {
|
||||
console.error('CodeMirror bundle not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab: Insert/Remove Indent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const val = this.value;
|
||||
const tabChar = " "; // 4 spaces
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Un-indent (naive single line)
|
||||
// TODO: Multiline support if needed. For now simple cursor unindent.
|
||||
// Checking previous chars
|
||||
// Not implemented for simplicity, just preventing focus loss.
|
||||
} else {
|
||||
// Insert Tab
|
||||
// Use setRangeText to preserve browser undo buffer if mixed usage?
|
||||
// But we have custom undo.
|
||||
this.value = val.substring(0, start) + tabChar + val.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + tabChar.length;
|
||||
saveState();
|
||||
cmView = MivoEditor.init({
|
||||
parent: container,
|
||||
initialValue: textarea.value,
|
||||
dark: isDark,
|
||||
onChange: (val) => {
|
||||
textarea.value = val;
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enter: Auto-indent checking previous line
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const val = this.value;
|
||||
|
||||
// Find start of current line
|
||||
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||||
const currentLine = val.substring(lineStart, start);
|
||||
|
||||
// Calculate indentation
|
||||
const match = currentLine.match(/^\s*/);
|
||||
const indent = match ? match[0] : '';
|
||||
|
||||
const insert = '\n' + indent;
|
||||
|
||||
this.value = val.substring(0, start) + insert + val.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + insert.length;
|
||||
|
||||
saveState(); // Immediate save on Enter
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
|
||||
function undo() {
|
||||
if (historyStack.length > 1) { // Keep initial state
|
||||
const current = historyStack.pop();
|
||||
redoStack.push(current);
|
||||
|
||||
const prev = historyStack[historyStack.length - 1];
|
||||
editor.value = prev.value;
|
||||
editor.selectionStart = prev.selectionStart;
|
||||
editor.selectionEnd = prev.selectionEnd;
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (redoStack.length > 0) {
|
||||
const next = redoStack.pop();
|
||||
historyStack.push(next);
|
||||
|
||||
editor.value = next.value;
|
||||
editor.selectionStart = next.selectionStart;
|
||||
editor.selectionEnd = next.selectionEnd;
|
||||
updatePreview();
|
||||
}
|
||||
// Set focus
|
||||
cmView.focus();
|
||||
}
|
||||
|
||||
function insertVar(text) {
|
||||
saveState(); // Save state before insertion
|
||||
if (!cmView) return;
|
||||
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const val = editor.value;
|
||||
editor.value = val.substring(0, start) + text + val.substring(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + text.length;
|
||||
editor.focus();
|
||||
|
||||
saveState(); // Save state after insertion
|
||||
updatePreview();
|
||||
const selection = cmView.state.selection.main;
|
||||
cmView.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: text },
|
||||
selection: { anchor: selection.from + text.length }
|
||||
});
|
||||
cmView.focus();
|
||||
}
|
||||
|
||||
// Live Preview Logic
|
||||
@@ -368,7 +285,7 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
};
|
||||
|
||||
function updatePreview() {
|
||||
let content = editor.value;
|
||||
let content = textarea.value;
|
||||
|
||||
// 1. Handle {{logo id=...}}
|
||||
content = content.replace(/\{\{logo\s+id=['"]?([^'"\s]+)['"]?\}\}/gi, (match, id) => {
|
||||
@@ -479,10 +396,39 @@ require_once ROOT . '/app/Views/layouts/header_main.php';
|
||||
preview.innerHTML = content;
|
||||
}
|
||||
|
||||
editor.addEventListener('input', updatePreview); // Handled by debouncer above too, but OK.
|
||||
|
||||
// Init
|
||||
updatePreview();
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initEditor();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// Theme Switch Recognition
|
||||
window.addEventListener('languageChanged', () => {
|
||||
// Not language, but theme toggle button often triggers layout shifts.
|
||||
// We might need a MutationObserver if we want to live-toggle CM theme.
|
||||
// For now, reload or manual re-init on theme toggle could work.
|
||||
});
|
||||
|
||||
// Watch for theme changes globally
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class' && mutation.target === document.documentElement) {
|
||||
// Theme changed
|
||||
// CodeMirror 6 themes are extensions, changing them requires re-configuring the state.
|
||||
// For simplicity, let's just re-init everything if theme changes.
|
||||
const newIsDark = document.documentElement.classList.contains('dark');
|
||||
if (cmView) {
|
||||
const content = cmView.state.doc.toString();
|
||||
container.innerHTML = '';
|
||||
cmView = null;
|
||||
textarea.value = content;
|
||||
initEditor();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
</script>
|
||||
|
||||
<?php require_once ROOT . '/app/Views/layouts/footer_main.php'; ?>
|
||||
|
||||
Reference in New Issue
Block a user