plugin infrastruktur
This commit is contained in:
1 parent
1545b90f39
commit
3f4ba3c0f4
35 files changed
+1303
-195
No files matched your search
@@ -89,8 +89,9 @@ hyprcrd/
|
||||
├── docker-entrypoint.sh
|
||||
├── dist.sh # Build distribution ZIP
|
||||
├── src/
|
||||
│ ├── bootstrap.php # Initialization
|
||||
│ ├── bootstrap.php # Initialization + plugin discovery
|
||||
│ ├── Database.php # SQLite schema & migrations
|
||||
│ ├── PluginManager.php # Plugin scanner & asset/slot renderer
|
||||
│ ├── CalDavClient.php # CalDAV HTTP client + iCal parser
|
||||
│ └── CalDavSync.php # Bidirectional CalDAV sync engine
|
||||
├── public/ # Document root
|
||||
@@ -103,9 +104,13 @@ hyprcrd/
|
||||
│ │ └── vendor/ # Phosphor Icons CSS + fonts
|
||||
│ ├── fonts/ # JetBrains Mono & Silkscreen
|
||||
│ ├── js/
|
||||
│ │ ├── hyprcrd.js # Plugin API (window.hyprcrd, sync-loaded)
|
||||
│ │ ├── app.js # Orchestrator
|
||||
│ │ ├── modules/ # Feature modules
|
||||
│ │ ├── modules/ # Core feature modules
|
||||
│ │ └── vendor/ # marked.js, DOMPurify
|
||||
│ ├── plugins/ # Optional, drop-in plugin folders
|
||||
│ │ ├── canvas/ # ASCII art canvas editor
|
||||
│ │ └── tests/ # UI + state test runner
|
||||
│ └── uploads/ # User media (writable)
|
||||
├── relay/ # Relay service (mobile inbox + card sharing)
|
||||
│ ├── index.php # Single-file API + Mobile UI
|
||||
@@ -121,7 +126,7 @@ hyprcrd/
|
||||
- **Cards** with markdown body, color, tags, and position
|
||||
- **Buckets** for organizing cards into groups
|
||||
- **Tags** written as `#tagname` or hierarchical `#parent/child` in card body, extracted automatically
|
||||
- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views
|
||||
- **Markdown** with `[[Card Title]]` wiki-links, ```` ```gallery ```` image galleries, ```` ```tree ```` list views, ```` ```ascii ```` art canvas (via canvas plugin)
|
||||
- **Search** combining text and `#tag` filters (e.g. `#project meeting notes`)
|
||||
- **Image gallery** with orphan detection and fullscreen viewer
|
||||
- **Split view** showing tag-related and content-related cards
|
||||
@@ -138,6 +143,101 @@ hyprcrd/
|
||||
- **Superview** compact multi-column list with hover cards
|
||||
- **Keyboard shortcuts**: `Ctrl+K` search, `F1` help, `Shift+scroll` zoom, arrow keys in autocomplete
|
||||
|
||||
## Plugins
|
||||
|
||||
hyprcrd has a drop-in plugin system. A plugin is just a folder under `public/plugins/` with a `plugin.json` manifest. The core scans the directory at startup, loads each plugin's CSS/JS/HTML, and the plugin self-registers against `window.hyprcrd`. Plugins are entirely optional — removing a plugin folder removes the feature, with no code changes anywhere else.
|
||||
|
||||
### Bundled plugins
|
||||
|
||||
- **`canvas/`** — ASCII art canvas editor. Adds a toolbar button + ` ```ascii ` fenced block. Draw with brush, line, rectangle, ellipse, fill, and text tools using a monospace character grid.
|
||||
- **`tests/`** — UI + state test runner (40+ test files). Loaded only when `?test` is in the URL or `Ctrl+Shift+T` is pressed, so production users pay no cost for it.
|
||||
|
||||
### Plugin layout
|
||||
|
||||
```
|
||||
public/plugins/myplugin/
|
||||
├── plugin.json # Manifest (required)
|
||||
├── myplugin.js # Entry point (loaded as ES module)
|
||||
├── myplugin.css # Optional styles
|
||||
├── myplugin.html # Optional HTML fragment (overlays, modals, etc.)
|
||||
└── backend.php # Optional PHP hooks
|
||||
```
|
||||
|
||||
**`plugin.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myplugin",
|
||||
"version": "1.0.0",
|
||||
"description": "What this plugin does",
|
||||
"frontend": {
|
||||
"js": ["myplugin.js"],
|
||||
"css": ["myplugin.css"],
|
||||
"html": ["myplugin.html"]
|
||||
},
|
||||
"backend": "backend.php"
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin API (`window.hyprcrd`)
|
||||
|
||||
Loaded synchronously before any module, so plugins can register handlers immediately:
|
||||
|
||||
| Namespace | Methods | Purpose |
|
||||
|---|---|---|
|
||||
| `hyprcrd.editor` | `registerCommand(name, fn)`, `getCommand(name)`, `api.{body, insertMd, updatePreview}` | Add toolbar commands. The `data-md="<name>"` button dispatches to the registered handler. `api` is set after the editor initializes. |
|
||||
| `hyprcrd.markdown` | `registerFencedBlock(lang, fn)`, `registerPreProcessor(fn)`, `registerPostProcessor(fn)` | Add ` ```<lang> ` block renderers (returns HTML, passes through DOMPurify). Pre/post processors run on the source/HTML around `marked.parse()`. |
|
||||
| `hyprcrd.hooks` | `on(name, fn)`, `off(name, fn)`, `emit(name, data)` | Generic event bus. Use for cross-plugin or core→plugin signaling. |
|
||||
| `hyprcrd.util` | `$(idOrSel)`, `$$(sel)`, `esc(s)` | Lightweight DOM/escape helpers so plugins don't need to import core modules. |
|
||||
| `hyprcrd.registerPlugin(meta)` | — | Register the plugin's metadata for introspection. |
|
||||
|
||||
### Minimal plugin example
|
||||
|
||||
`public/plugins/hello/plugin.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hello",
|
||||
"version": "1.0.0",
|
||||
"description": "Inserts a greeting",
|
||||
"frontend": { "js": ["hello.js"] }
|
||||
}
|
||||
```
|
||||
|
||||
`public/plugins/hello/hello.js`:
|
||||
|
||||
```js
|
||||
const h = window.hyprcrd;
|
||||
|
||||
h.editor.registerCommand('hello', () => {
|
||||
h.editor.api.insertMd('Hello, world! ', '', '');
|
||||
});
|
||||
|
||||
// Inject a toolbar button
|
||||
const btn = document.createElement('button');
|
||||
btn.dataset.md = 'hello';
|
||||
btn.className = 'toolbar-btn';
|
||||
btn.title = 'Hello';
|
||||
btn.textContent = '👋';
|
||||
document.getElementById('md-toolbar').appendChild(btn);
|
||||
|
||||
h.registerPlugin({ name: 'hello', version: '1.0.0' });
|
||||
```
|
||||
|
||||
That's the entire plugin. Drop the folder in, reload, the button appears.
|
||||
|
||||
### Backend hooks (PHP)
|
||||
|
||||
If a plugin needs server-side code, set `"backend": "backend.php"` in the manifest. The file is `require_once`'d during bootstrap. Backend plugins can use `PluginManager::instance()->addToSlot('slot-name', '<html>')` to inject HTML into named slots in `index.php` (e.g. `md-toolbar`).
|
||||
|
||||
### Removing a plugin
|
||||
|
||||
```bash
|
||||
rm -rf public/plugins/canvas
|
||||
```
|
||||
|
||||
That's it. The toolbar button, overlay, CSS, and markdown handler all disappear together. The core has no awareness of any specific plugin.
|
||||
|
||||
## Data & Backup
|
||||
|
||||
All data lives in two places:
|
||||
@@ -318,8 +418,8 @@ After changing Tailwind utility classes in HTML or JS files:
|
||||
# Download standalone CLI (once)
|
||||
curl -sL https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 -o tailwindcss && chmod +x tailwindcss
|
||||
|
||||
# Build
|
||||
./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js" --minify
|
||||
# Build (includes plugins so plugin HTML/JS contributes utility classes)
|
||||
./tailwindcss --input public/css/tailwind.css --output public/css/tailwind.out.css --content "public/index.php,public/js/**/*.js,public/plugins/**/*.{js,html}" --minify
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
@@ -499,16 +499,6 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
#editor-gutter > div { padding-right: 2px; box-sizing: border-box; }
|
||||
.gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); }
|
||||
|
||||
/* ASCII Canvas */
|
||||
#ascii-canvas-overlay { background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); }
|
||||
.ascii-grid { white-space: pre; user-select: none; -webkit-user-select: none; display: inline-block; width: max-content; }
|
||||
.ascii-cursor { background: var(--t-accent); color: var(--t-bg); }
|
||||
.ascii-char-btn { font-family: var(--t-font-mono, monospace); font-size: 13px; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--t-border); border-radius: 3px; background: var(--t-bg); color: var(--t-text); cursor: pointer; transition: all 0.1s; padding: 0; line-height: 1; }
|
||||
.ascii-char-btn:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||
.ascii-char-btn.active { background: var(--t-accent); color: var(--t-bg); border-color: var(--t-accent); }
|
||||
.ascii-char-group { display: inline-flex; gap: 2px; margin-right: 6px; }
|
||||
#ascii-canvas-toolbar .toolbar-btn.active { background: var(--t-accent); color: var(--t-bg); }
|
||||
|
||||
/* Search */
|
||||
#search { backdrop-filter: blur(4px); }
|
||||
#search-input { caret-color: var(--t-accent); }
|
||||
@@ -563,7 +553,6 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
.prose code { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.88em; }
|
||||
.prose pre { background: var(--t-bg); border: 1px solid var(--t-border); padding: 0.8em 1em; overflow-x: auto; margin: 0.8em 0; border-radius: 3px; position: relative; }
|
||||
.prose pre code { background: none; border: none; padding: 0; font-size: 0.85em; }
|
||||
.prose pre.ascii-art { line-height: 1.35; letter-spacing: 0; white-space: pre; font-size: 0.85em; }
|
||||
.prose pre .code-copy {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: var(--t-surface); border: 1px solid var(--t-border);
|
||||
|
||||
+50
-46
@@ -301,6 +301,33 @@ if (str_starts_with($route, '/api/')) {
|
||||
$card->execute(['id' => $m[1]]); echo json_encode($card->fetch()); exit;
|
||||
}
|
||||
|
||||
// Plugins
|
||||
if ($route === '/api/plugins' && $method === 'GET') {
|
||||
echo json_encode(PluginManager::instance()->pluginsForApi()); exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/plugins/toggle' && $method === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
$enabled = !empty($input['enabled']);
|
||||
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
|
||||
$pm = PluginManager::instance();
|
||||
$known = array_keys($pm->plugins());
|
||||
if (!in_array($name, $known, true)) {
|
||||
http_response_code(404); echo json_encode(['error' => 'plugin not found']); exit;
|
||||
}
|
||||
$disabled = $pm->disabledList();
|
||||
if ($enabled) {
|
||||
$disabled = array_values(array_diff($disabled, [$name]));
|
||||
} else if (!in_array($name, $disabled, true)) {
|
||||
$disabled[] = $name;
|
||||
}
|
||||
if (!$pm->setDisabled($disabled)) {
|
||||
http_response_code(500); echo json_encode(['error' => 'failed to write state']); exit;
|
||||
}
|
||||
echo json_encode(['ok' => true, 'plugins' => $pm->pluginsForApi()]); exit;
|
||||
}
|
||||
|
||||
// Buckets
|
||||
if ($route === '/api/buckets' && $method === 'GET') {
|
||||
echo json_encode($db->query('SELECT * FROM buckets ORDER BY position ASC, created_at ASC')->fetchAll()); exit;
|
||||
@@ -1012,6 +1039,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<script src="js/vendor/marked.min.js"></script>
|
||||
<script src="js/vendor/purify.min.js"></script>
|
||||
<link rel="stylesheet" href="css/style.css?v=<?= time() ?>">
|
||||
<?= PluginManager::instance()->cssLinks() ?>
|
||||
<script>
|
||||
window.themes = {
|
||||
slate: { label:'Slate', vars:{ '--t-bg':'#1e293b','--t-surface':'#273349','--t-raised':'#2e3d56','--t-border':'#384a64','--t-dim':'#64748b','--t-muted':'#94a3b8','--t-text':'#cbd5e1','--t-bright':'#f1f5f9','--t-accent':'#38bdf8','--t-accent2':'#818cf8','--t-danger':'#fb7185','--t-scanline':'rgba(0,0,0,0.04)','--t-overlay':'rgba(15,23,42,0.75)','--t-font-pixel':'"Silkscreen", cursive','--t-cover-gradient':'linear-gradient(180deg, rgba(15,23,42,0.22) 0%, rgba(15,23,42,0.7) 62%, rgba(15,23,42,0.86) 100%)' }},
|
||||
@@ -1350,6 +1378,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button class="settings-tab" data-tab="relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">relay</span></button>
|
||||
<button class="settings-tab" data-tab="behavior"><i class="ph ph-sliders text-sm"></i><span class="font-pixel text-[10px]">behavior</span></button>
|
||||
<button class="settings-tab" data-tab="notifications"><i class="ph ph-bell text-sm"></i><span class="font-pixel text-[10px]">notifications</span></button>
|
||||
<button class="settings-tab" data-tab="plugins"><i class="ph ph-puzzle-piece text-sm"></i><span class="font-pixel text-[10px]">plugins</span></button>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
||||
@@ -1521,6 +1550,18 @@ if (str_starts_with($route, '/api/')) {
|
||||
<div id="notif-stream" class="max-h-64 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-panel-plugins" class="hidden space-y-4">
|
||||
<div>
|
||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
|
||||
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggling requires a page reload to take effect.</p>
|
||||
<div id="plugins-list" class="space-y-2"></div>
|
||||
<div id="plugins-reload-notice" class="hidden mt-3 flex items-center gap-2 px-3 py-2 border border-t-accent bg-t-accent/10 text-xs text-t-accent">
|
||||
<i class="ph ph-warning text-sm"></i>
|
||||
<span class="flex-1 font-pixel text-[10px]">reload required to apply changes</span>
|
||||
<button id="plugins-reload-btn" class="font-pixel text-[10px] underline hover:text-t-bright">reload now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-panel-account" class="hidden space-y-4">
|
||||
<div>
|
||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-key text-xs"></i> vault key</div>
|
||||
@@ -1826,7 +1867,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button data-md="hr" title="Divider" class="toolbar-btn"><i class="ph ph-minus text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||
<button data-md="asciiart" title="ASCII Canvas" class="toolbar-btn"><i class="ph ph-grid-four text-sm"></i></button>
|
||||
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||
<div class="flex-1"></div>
|
||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" title="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">preview</span></button>
|
||||
@@ -1843,51 +1884,14 @@ if (str_starts_with($route, '/api/')) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ASCII Canvas Overlay -->
|
||||
<div class="overlay fixed inset-0" id="ascii-canvas-overlay" style="z-index:80;">
|
||||
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
|
||||
<button data-tool="brush" class="toolbar-btn active" title="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
<button data-tool="eraser" class="toolbar-btn" title="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
|
||||
<button data-tool="line" class="toolbar-btn" title="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
|
||||
<button data-tool="rect" class="toolbar-btn" title="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
|
||||
<button data-tool="circle" class="toolbar-btn" title="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
|
||||
<button data-tool="fill" class="toolbar-btn" title="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
|
||||
<button data-tool="text" class="toolbar-btn" title="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>Char:</span>
|
||||
<input id="ascii-char-input" type="text" value="#" maxlength="1" class="w-6 text-center bg-t-bg border border-t-border rounded text-t-text text-sm font-mono px-0 py-0">
|
||||
</label>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>W:</span>
|
||||
<input id="ascii-canvas-width" type="number" value="40" min="5" max="200" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>H:</span>
|
||||
<input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||
</label>
|
||||
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" title="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" title="Clear"><i class="ph ph-trash text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
|
||||
</div>
|
||||
<!-- Char palette -->
|
||||
<div id="ascii-char-palette" class="flex items-center gap-0.5 px-3 py-1.5 border-b border-t-border shrink-0 flex-wrap overflow-x-auto"></div>
|
||||
<!-- Grid -->
|
||||
<div class="flex-1 min-h-0 overflow-auto bg-t-bg p-2">
|
||||
<pre id="ascii-canvas-grid" class="ascii-grid font-mono text-sm text-t-text select-none cursor-crosshair" style="line-height:1.35;letter-spacing:0;margin:0;"></pre>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="ascii-canvas-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
<button id="ascii-canvas-ok" class="bg-t-accent text-t-bg text-xs font-bold px-4 py-1 rounded hover:opacity-90 cursor-pointer">Insert</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Plugin HTML fragments (overlays, modals, etc.) -->
|
||||
<?= PluginManager::instance()->htmlFragments() ?>
|
||||
|
||||
<!-- Plugin API: must load BEFORE any ES module so window.hyprcrd exists -->
|
||||
<script src="js/hyprcrd.js?v=<?= time() ?>"></script>
|
||||
|
||||
<!-- Plugin entry points (load before app.js so they self-register first) -->
|
||||
<?= PluginManager::instance()->scriptTags() ?>
|
||||
|
||||
<script type="module" src="js/app.js?v=<?= time() ?>"></script>
|
||||
</body>
|
||||
|
||||
+3
-10
@@ -17,6 +17,7 @@ import { openGallery, closeGallery } from './modules/gallery.js';
|
||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||
import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js';
|
||||
import { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.js';
|
||||
import { openPluginSettings } from './modules/plugin-settings.js';
|
||||
|
||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||
|
||||
@@ -28,6 +29,7 @@ window._closeTasks = closeTasks;
|
||||
window._renderBoard = renderBoard;
|
||||
window._openCalendar = openCalendar;
|
||||
window._triggerSync = triggerSync;
|
||||
window._openPluginSettings = openPluginSettings;
|
||||
window._loadCaldavSettings = loadCaldavSettings;
|
||||
window._loadRelaySettings = loadRelaySettings;
|
||||
window._renderTagBar = renderTagBar;
|
||||
@@ -277,13 +279,4 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
||||
|
||||
checkAuth();
|
||||
|
||||
// ── Test runner (lazy-loaded, no cost in production) ──
|
||||
if (new URLSearchParams(location.search).has('test') || false) {
|
||||
import('./test/tests.js');
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
import('./test/tests.js').then(() => import('./test/runner.js').then(m => m.open()));
|
||||
}
|
||||
});
|
||||
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──
|
||||
@@ -0,0 +1,135 @@
|
||||
// ── 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();
|
||||
},
|
||||
};
|
||||
|
||||
// ── Util helpers (mirror of internal util.js) ───────────────────
|
||||
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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
},
|
||||
};
|
||||
|
||||
// ── 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,
|
||||
plugins,
|
||||
registerPlugin,
|
||||
version: '1.0',
|
||||
};
|
||||
})();
|
||||
+63
-79
@@ -4,7 +4,6 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
|
||||
import { state, getChildren, CARD_COLORS } from './state.js';
|
||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||
import { openCanvas } from './canvas.js';
|
||||
|
||||
const modal = $('modal');
|
||||
const body = $('editor-body');
|
||||
@@ -442,87 +441,72 @@ function insertMd(before, after = '', placeholder = '') {
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// ── Built-in editor commands ──
|
||||
// All commands flow through the plugin command registry. Plugins can
|
||||
// register their own data-md="..." commands via window.hyprcrd.editor.
|
||||
const _hyprEd = (typeof window !== 'undefined' && window.hyprcrd?.editor) || null;
|
||||
const _registerCmd = (name, fn) => {
|
||||
if (_hyprEd) _hyprEd.registerCommand(name, fn);
|
||||
};
|
||||
|
||||
_registerCmd('heading', () => insertMd('## ', '', 'Heading'));
|
||||
_registerCmd('bold', () => insertMd('**', '**', 'bold'));
|
||||
_registerCmd('italic', () => insertMd('*', '*', 'italic'));
|
||||
_registerCmd('strikethrough', () => insertMd('~~', '~~', 'text'));
|
||||
_registerCmd('code', () => insertMd('`', '`', 'code'));
|
||||
_registerCmd('codeblock', () => insertMd('```\n', '\n```', 'code'));
|
||||
_registerCmd('ul', () => insertMd('- ', '', 'item'));
|
||||
_registerCmd('ol', () => insertMd('1. ', '', 'item'));
|
||||
_registerCmd('tree', () => insertMd('```tree\n- ', '\n - child\n', 'parent'));
|
||||
_registerCmd('quote', () => insertMd('> ', '', 'quote'));
|
||||
_registerCmd('todo', () => {
|
||||
const s = body.selectionStart, e = body.selectionEnd;
|
||||
const sel = body.value.substring(s, e);
|
||||
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
||||
const lines = sel.split('\n');
|
||||
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
||||
const converted = lines.map(l => {
|
||||
const trimmed = l.trim();
|
||||
if (!trimmed) return l;
|
||||
if (hasTodos) return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
||||
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
||||
}).join('\n');
|
||||
body.setRangeText(converted, s, e, 'select');
|
||||
body.selectionStart = s;
|
||||
body.selectionEnd = s + converted.length;
|
||||
updatePreview();
|
||||
} else {
|
||||
insertMd('- [ ] ', '', 'task');
|
||||
}
|
||||
});
|
||||
_registerCmd('link', () => insertMd('[', '](url)', 'text'));
|
||||
_registerCmd('cardlink', () => { insertMd('[[', ']]', ''); showLinkAc(''); });
|
||||
_registerCmd('extract', async () => {
|
||||
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
|
||||
if (!sel.trim()) return;
|
||||
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
|
||||
await window._extractToCard(sel);
|
||||
const link = `[[${title}]]`;
|
||||
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
|
||||
updatePreview();
|
||||
});
|
||||
_registerCmd('hr', () => insertMd('\n---\n'));
|
||||
|
||||
// Expose editor API to plugins (textarea, insertMd, preview refresh)
|
||||
if (_hyprEd) {
|
||||
_hyprEd.api = {
|
||||
body,
|
||||
insertMd,
|
||||
updatePreview,
|
||||
};
|
||||
}
|
||||
|
||||
// Toolbar dispatch routes through the plugin registry
|
||||
$('md-toolbar')?.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-md]');
|
||||
if (!btn) return;
|
||||
const cmds = {
|
||||
heading: () => insertMd('## ', '', 'Heading'), bold: () => insertMd('**', '**', 'bold'),
|
||||
italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'),
|
||||
code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'),
|
||||
ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'),
|
||||
tree: () => insertMd('```tree\n- ', '\n - child\n', 'parent'),
|
||||
quote: () => insertMd('> ', '', 'quote'),
|
||||
todo: () => {
|
||||
const s = body.selectionStart, e = body.selectionEnd;
|
||||
const sel = body.value.substring(s, e);
|
||||
if (sel && sel.includes('\n') || (sel && /^[\s]*[-*+]\s/m.test(sel))) {
|
||||
// Multi-line or bullet list: convert each line
|
||||
const lines = sel.split('\n');
|
||||
const hasTodos = lines.some(l => /^\s*- \[[ x]\]/.test(l));
|
||||
const converted = lines.map(l => {
|
||||
const trimmed = l.trim();
|
||||
if (!trimmed) return l;
|
||||
if (hasTodos) {
|
||||
// Toggle back: remove checkbox
|
||||
return l.replace(/^(\s*)- \[[ x]\] /, '$1- ');
|
||||
}
|
||||
// Add checkbox: strip existing bullet if present
|
||||
return l.replace(/^(\s*)(?:[-*+]|\d+\.)\s/, '$1').replace(/^(\s*)(.+)$/, '$1- [ ] $2');
|
||||
}).join('\n');
|
||||
body.setRangeText(converted, s, e, 'select');
|
||||
body.selectionStart = s;
|
||||
body.selectionEnd = s + converted.length;
|
||||
updatePreview();
|
||||
} else {
|
||||
insertMd('- [ ] ', '', 'task');
|
||||
}
|
||||
},
|
||||
link: () => insertMd('[', '](url)', 'text'),
|
||||
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
|
||||
extract: async () => {
|
||||
const sel = body.value.substring(body.selectionStart, body.selectionEnd);
|
||||
if (!sel.trim()) return;
|
||||
const title = sel.trim().split('\n')[0].replace(/^#+\s*/, '').replace(/^[-*]\s*/, '').trim();
|
||||
await window._extractToCard(sel);
|
||||
// Replace selection with a card link
|
||||
const link = `[[${title}]]`;
|
||||
body.setRangeText(link, body.selectionStart, body.selectionEnd, 'end');
|
||||
updatePreview();
|
||||
},
|
||||
hr: () => insertMd('\n---\n'),
|
||||
asciiart: () => {
|
||||
// Check if cursor is inside an existing ```ascii block to edit it
|
||||
const val = body.value;
|
||||
const pos = body.selectionStart;
|
||||
const before = val.substring(0, pos);
|
||||
const after = val.substring(pos);
|
||||
const blockStart = before.lastIndexOf('```ascii\n');
|
||||
const blockEndInBefore = blockStart >= 0 ? before.indexOf('\n```', blockStart + 9) : -1;
|
||||
let existingText = null, replaceStart = 0, replaceEnd = 0;
|
||||
if (blockStart >= 0 && blockEndInBefore < 0) {
|
||||
// Cursor is inside a ```ascii block
|
||||
const contentStart = blockStart + 9; // after "```ascii\n"
|
||||
const endMark = val.indexOf('\n```', contentStart);
|
||||
if (endMark >= 0) {
|
||||
existingText = val.substring(contentStart, endMark);
|
||||
replaceStart = blockStart;
|
||||
replaceEnd = endMark + 4;
|
||||
}
|
||||
}
|
||||
openCanvas(text => {
|
||||
const block = '```ascii\n' + text + '\n```';
|
||||
if (existingText !== null) {
|
||||
body.setRangeText(block, replaceStart, replaceEnd, 'end');
|
||||
} else {
|
||||
const s = body.selectionStart;
|
||||
const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : '';
|
||||
body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end');
|
||||
}
|
||||
updatePreview();
|
||||
}, existingText);
|
||||
},
|
||||
};
|
||||
cmds[btn.dataset.md]?.();
|
||||
const fn = _hyprEd?.getCommand(btn.dataset.md);
|
||||
if (fn) fn();
|
||||
});
|
||||
|
||||
const editorDivider = $('editor-divider');
|
||||
|
||||
@@ -11,6 +11,32 @@ marked.use({ breaks: true, gfm: true, renderer: {
|
||||
}
|
||||
}});
|
||||
|
||||
// ── Built-in fenced block handlers ──
|
||||
// Registered through the same plugin API so plugins and core use one path.
|
||||
if (typeof window !== 'undefined' && window.hyprcrd?.markdown) {
|
||||
const md = window.hyprcrd.markdown;
|
||||
|
||||
// ```gallery — image grid
|
||||
md.registerFencedBlock('gallery', (content, { esc: e }) => {
|
||||
const imgs = [];
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
||||
if (!imgs.length) return '';
|
||||
return '<div class="md-gallery">' + imgs.map(i =>
|
||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${e(i.alt)}" loading="lazy"></div>`
|
||||
).join('') + '</div>';
|
||||
});
|
||||
|
||||
// ```tree / ```bullets — custom list rendering (handled via post-processor)
|
||||
const listHandler = (kind) => (content, { normalizeListBlock: norm }) => {
|
||||
const normalized = norm(content);
|
||||
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
||||
};
|
||||
md.registerFencedBlock('tree', listHandler('tree'));
|
||||
md.registerFencedBlock('bullets', listHandler('bullets'));
|
||||
}
|
||||
|
||||
const purifyConfig = {
|
||||
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
|
||||
ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'],
|
||||
@@ -79,27 +105,32 @@ export function renderMd(src) {
|
||||
'</tbody></table>\n';
|
||||
});
|
||||
|
||||
// ── Fenced block registry ─────────────────────────────────────
|
||||
// Apply all registered ```<lang> handlers (built-in + plugins).
|
||||
// Plugins register via window.hyprcrd.markdown.registerFencedBlock().
|
||||
const fencedBlocks = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getFencedBlocks)
|
||||
? window.hyprcrd.markdown.getFencedBlocks()
|
||||
: [];
|
||||
for (const { lang, handler } of fencedBlocks) {
|
||||
// Escape regex metachars in lang (allow simple identifiers)
|
||||
const safeLang = String(lang).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp('```' + safeLang + '\\s*\\n([\\s\\S]*?)(?:```|$)', 'g');
|
||||
processed = processed.replace(re, (_, content) => {
|
||||
try { return handler(content, { esc, normalizeListBlock }); }
|
||||
catch (err) { console.error(`[markdown fenced ${lang}]`, err); return ''; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Plugin pre-processors ─────────────────────────────────────
|
||||
const preProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPreProcessors)
|
||||
? window.hyprcrd.markdown.getPreProcessors()
|
||||
: [];
|
||||
for (const fn of preProcessors) {
|
||||
try { processed = fn(processed) ?? processed; }
|
||||
catch (err) { console.error('[markdown pre]', err); }
|
||||
}
|
||||
|
||||
processed = processed
|
||||
// ASCII canvas block: ```ascii
|
||||
.replace(/```ascii\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
||||
return `<pre class="ascii-art">${esc(content.replace(/\s+$/, ''))}</pre>`;
|
||||
})
|
||||
// Gallery fenced block: ```gallery
|
||||
.replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
|
||||
const imgs = [];
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
|
||||
if (!imgs.length) return '';
|
||||
return '<div class="md-gallery">' + imgs.map(i =>
|
||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${esc(i.alt)}" loading="lazy"></div>`
|
||||
).join('') + '</div>';
|
||||
})
|
||||
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
|
||||
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
|
||||
const normalized = normalizeListBlock(content);
|
||||
return `\n<!-- list:${kind} -->\n${normalized}\n`;
|
||||
})
|
||||
// Help parser: ensure a list can start after text without requiring manual blank line
|
||||
.replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n')
|
||||
// Preserve extra blank lines as <br> spacers (3+ newlines = 1+ extra blank lines)
|
||||
@@ -124,6 +155,15 @@ export function renderMd(src) {
|
||||
html = html.replace(/<!--\s*list:tree\s*-->\s*<ul>/g, '<ul class="tree-list">');
|
||||
html = html.replace(/<!--\s*list:bullets\s*-->\s*<ul>/g, '<ul class="bullet-list">');
|
||||
|
||||
// ── Plugin post-processors (run on HTML, before sanitize) ──
|
||||
const postProcessors = (typeof window !== 'undefined' && window.hyprcrd?.markdown?.getPostProcessors)
|
||||
? window.hyprcrd.markdown.getPostProcessors()
|
||||
: [];
|
||||
for (const fn of postProcessors) {
|
||||
try { html = fn(html) ?? html; }
|
||||
catch (err) { console.error('[markdown post]', err); }
|
||||
}
|
||||
|
||||
html = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html, purifyConfig) : html;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// ── Plugin Settings UI ──
|
||||
// Renders the list of installed plugins with on/off toggles in the
|
||||
// settings overlay. Disabled plugins are skipped at PHP render time;
|
||||
// toggling requires a reload to take effect (page must re-fetch the
|
||||
// new script/css/html tag set).
|
||||
|
||||
import { $, esc, api } from './util.js';
|
||||
|
||||
let pluginsCache = null;
|
||||
let dirtyAfterToggle = false;
|
||||
|
||||
async function fetchPlugins() {
|
||||
const res = await fetch('api/plugins');
|
||||
if (!res.ok) throw new Error('failed to load plugins');
|
||||
pluginsCache = await res.json();
|
||||
return pluginsCache;
|
||||
}
|
||||
|
||||
function render(list) {
|
||||
const container = $('plugins-list');
|
||||
if (!container) return;
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list.map(p => `
|
||||
<div class="flex items-start gap-3 px-3 py-2 border border-t-border bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
||||
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
||||
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
||||
</div>
|
||||
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
||||
</div>
|
||||
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
||||
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${p.enabled ? 'checked' : ''} data-name="${esc(p.name)}">
|
||||
</label>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
||||
input.addEventListener('change', onToggle);
|
||||
});
|
||||
}
|
||||
|
||||
async function onToggle(e) {
|
||||
const input = e.currentTarget;
|
||||
const name = input.dataset.name;
|
||||
const enabled = input.checked;
|
||||
input.disabled = true;
|
||||
try {
|
||||
const res = await api('api/plugins/toggle', {
|
||||
method: 'POST',
|
||||
body: { name, enabled },
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
pluginsCache = res.plugins;
|
||||
dirtyAfterToggle = true;
|
||||
$('plugins-reload-notice')?.classList.remove('hidden');
|
||||
} catch (err) {
|
||||
// Revert UI on failure
|
||||
input.checked = !enabled;
|
||||
console.error('[plugins] toggle failed:', err);
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openPluginSettings() {
|
||||
const container = $('plugins-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
||||
if (!dirtyAfterToggle) {
|
||||
$('plugins-reload-notice')?.classList.add('hidden');
|
||||
}
|
||||
try {
|
||||
const list = await fetchPlugins();
|
||||
render(list);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<p class="text-xs text-t-danger">Failed to load plugins: ${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Wire reload button once at module load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
|
||||
}, { once: true });
|
||||
// In case DOMContentLoaded already fired (modules are deferred but safe to retry)
|
||||
$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
|
||||
@@ -36,6 +36,7 @@ overlay?.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
if (panel === 'relay') { window._loadRelaySettings?.(); }
|
||||
if (panel === 'notifications') renderNotifSettings();
|
||||
if (panel === 'behavior') initBehaviorSettings();
|
||||
if (panel === 'plugins') window._openPluginSettings?.();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Canvas Plugin styles */
|
||||
#ascii-canvas-overlay { background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); }
|
||||
.ascii-grid {
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: inline-block;
|
||||
width: max-content;
|
||||
}
|
||||
.ascii-cursor { background: var(--t-accent); color: var(--t-bg); }
|
||||
.ascii-char-btn {
|
||||
font-family: var(--t-font-mono, monospace);
|
||||
font-size: 13px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--t-border);
|
||||
border-radius: 3px;
|
||||
background: var(--t-bg);
|
||||
color: var(--t-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.ascii-char-btn:hover { background: var(--t-raised); color: var(--t-bright); }
|
||||
.ascii-char-btn.active {
|
||||
background: var(--t-accent);
|
||||
color: var(--t-bg);
|
||||
border-color: var(--t-accent);
|
||||
}
|
||||
.ascii-char-group { display: inline-flex; gap: 2px; margin-right: 6px; }
|
||||
#ascii-canvas-toolbar .toolbar-btn.active { background: var(--t-accent); color: var(--t-bg); }
|
||||
|
||||
/* Preview rendering of ```ascii blocks */
|
||||
.prose pre.ascii-art {
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
white-space: pre;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!-- Canvas Plugin: ASCII art overlay -->
|
||||
<div class="overlay fixed inset-0" id="ascii-canvas-overlay" style="z-index:80;">
|
||||
<div class="ascii-canvas-modal bg-t-surface border border-t-border rounded-lg shadow-xl flex flex-col" style="width:90vw;max-width:960px;height:85vh;max-height:700px;margin:auto;position:absolute;inset:0;">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-1 px-3 py-2 border-b border-t-border shrink-0 flex-wrap" id="ascii-canvas-toolbar">
|
||||
<button data-tool="brush" class="toolbar-btn active" title="Brush (B)"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
<button data-tool="eraser" class="toolbar-btn" title="Eraser (E)"><i class="ph ph-eraser text-sm"></i></button>
|
||||
<button data-tool="line" class="toolbar-btn" title="Line (L)"><i class="ph ph-line-segment text-sm"></i></button>
|
||||
<button data-tool="rect" class="toolbar-btn" title="Rectangle (R)"><i class="ph ph-rectangle text-sm"></i></button>
|
||||
<button data-tool="circle" class="toolbar-btn" title="Ellipse (O)"><i class="ph ph-circle text-sm"></i></button>
|
||||
<button data-tool="fill" class="toolbar-btn" title="Fill (F)"><i class="ph ph-paint-bucket text-sm"></i></button>
|
||||
<button data-tool="text" class="toolbar-btn" title="Text (T)"><i class="ph ph-text-t text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>Char:</span>
|
||||
<input id="ascii-char-input" type="text" value="#" maxlength="1" class="w-6 text-center bg-t-bg border border-t-border rounded text-t-text text-sm font-mono px-0 py-0">
|
||||
</label>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>W:</span>
|
||||
<input id="ascii-canvas-width" type="number" value="40" min="5" max="200" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||
</label>
|
||||
<label class="flex items-center gap-1 text-t-muted text-xs">
|
||||
<span>H:</span>
|
||||
<input id="ascii-canvas-height" type="number" value="20" min="3" max="100" class="w-12 bg-t-bg border border-t-border rounded text-t-text text-xs text-center px-1 py-0">
|
||||
</label>
|
||||
<button id="ascii-canvas-resize" class="toolbar-btn text-xs" title="Resize"><i class="ph ph-arrows-out text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="ascii-canvas-clear" class="toolbar-btn text-xs" title="Clear"><i class="ph ph-trash text-sm"></i></button>
|
||||
<div class="flex-1"></div>
|
||||
<span id="ascii-canvas-coords" class="text-t-dim text-xs font-mono">1:1</span>
|
||||
</div>
|
||||
<!-- Char palette -->
|
||||
<div id="ascii-char-palette" class="flex items-center gap-0.5 px-3 py-1.5 border-b border-t-border shrink-0 flex-wrap overflow-x-auto"></div>
|
||||
<!-- Grid -->
|
||||
<div class="flex-1 min-h-0 overflow-auto bg-t-bg p-2">
|
||||
<pre id="ascii-canvas-grid" class="ascii-grid font-mono text-sm text-t-text select-none cursor-crosshair" style="line-height:1.35;letter-spacing:0;margin:0;"></pre>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 px-3 py-2 border-t border-t-border shrink-0">
|
||||
<button id="ascii-canvas-cancel" class="toolbar-btn text-xs px-3 py-1">Cancel</button>
|
||||
<button id="ascii-canvas-ok" class="bg-t-accent text-t-bg text-xs font-bold px-4 py-1 rounded hover:opacity-90 cursor-pointer">Insert</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,16 @@
|
||||
// ── ASCII Art Canvas ──
|
||||
// ── Canvas Plugin: ASCII Art Editor ──
|
||||
//
|
||||
// Self-registers via window.hyprcrd:
|
||||
// - Markdown fenced block ```ascii``` → <pre class="ascii-art">
|
||||
// - Editor toolbar button + "asciiart" command
|
||||
// - Re-opens canvas to edit if cursor is inside an existing block
|
||||
|
||||
import { $ } from './util.js';
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) {
|
||||
throw new Error('[canvas plugin] window.hyprcrd not found — plugin API missing');
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const overlay = $('ascii-canvas-overlay');
|
||||
const grid = $('ascii-canvas-grid');
|
||||
@@ -506,4 +516,73 @@ function init() {
|
||||
|
||||
init();
|
||||
|
||||
export { closeCanvas };
|
||||
// ── Plugin self-registration ──────────────────────────────────────
|
||||
|
||||
// Register markdown fenced block: ```ascii``` → styled <pre>
|
||||
hyprcrd.markdown.registerFencedBlock('ascii', (content, { esc }) => {
|
||||
return `<pre class="ascii-art">${esc(content.replace(/\s+$/, ''))}</pre>`;
|
||||
});
|
||||
|
||||
// Register editor toolbar command "asciiart"
|
||||
hyprcrd.editor.registerCommand('asciiart', () => {
|
||||
const ed = hyprcrd.editor.api;
|
||||
if (!ed || !ed.body) return;
|
||||
const body = ed.body;
|
||||
|
||||
// Detect if cursor is inside an existing ```ascii``` block (edit mode)
|
||||
const val = body.value;
|
||||
const pos = body.selectionStart;
|
||||
const before = val.substring(0, pos);
|
||||
const blockStart = before.lastIndexOf('```ascii\n');
|
||||
const blockEndInBefore = blockStart >= 0 ? before.indexOf('\n```', blockStart + 9) : -1;
|
||||
let existingText = null, replaceStart = 0, replaceEnd = 0;
|
||||
if (blockStart >= 0 && blockEndInBefore < 0) {
|
||||
const contentStart = blockStart + 9;
|
||||
const endMark = val.indexOf('\n```', contentStart);
|
||||
if (endMark >= 0) {
|
||||
existingText = val.substring(contentStart, endMark);
|
||||
replaceStart = blockStart;
|
||||
replaceEnd = endMark + 4;
|
||||
}
|
||||
}
|
||||
|
||||
openCanvas(text => {
|
||||
const block = '```ascii\n' + text + '\n```';
|
||||
if (existingText !== null) {
|
||||
body.setRangeText(block, replaceStart, replaceEnd, 'end');
|
||||
} else {
|
||||
const s = body.selectionStart;
|
||||
const prefix = s > 0 && val[s - 1] !== '\n' ? '\n' : '';
|
||||
body.setRangeText(prefix + block + '\n', s, body.selectionEnd, 'end');
|
||||
}
|
||||
ed.updatePreview?.();
|
||||
}, existingText);
|
||||
});
|
||||
|
||||
// Inject toolbar button into #md-toolbar (after the "extract" button if present)
|
||||
function injectToolbarButton() {
|
||||
const toolbar = $('md-toolbar');
|
||||
if (!toolbar) return;
|
||||
if (toolbar.querySelector('[data-md="asciiart"]')) return; // already injected
|
||||
const btn = document.createElement('button');
|
||||
btn.setAttribute('data-md', 'asciiart');
|
||||
btn.setAttribute('title', 'ASCII Canvas');
|
||||
btn.className = 'toolbar-btn';
|
||||
btn.innerHTML = '<i class="ph ph-grid-four text-sm"></i>';
|
||||
// Insert before the right-side spacer (flex-1 div), or append at end
|
||||
const spacer = toolbar.querySelector('.flex-1');
|
||||
if (spacer) toolbar.insertBefore(btn, spacer);
|
||||
else toolbar.appendChild(btn);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectToolbarButton);
|
||||
} else {
|
||||
injectToolbarButton();
|
||||
}
|
||||
|
||||
// Register plugin metadata for introspection
|
||||
hyprcrd.registerPlugin({
|
||||
name: 'canvas',
|
||||
version: '1.0.0',
|
||||
description: 'ASCII art canvas editor',
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "canvas",
|
||||
"version": "1.0.0",
|
||||
"description": "ASCII art canvas editor — draw with characters in a monospace grid and embed via ```ascii blocks.",
|
||||
"frontend": {
|
||||
"js": ["canvas.js"],
|
||||
"css": ["canvas.css"],
|
||||
"html": ["canvas.html"]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Bucket Bar', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
async function openEditor() {
|
||||
click('#add-btn');
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
async function openCanvas() {
|
||||
await openEditor();
|
||||
const btn = document.querySelector('[data-md="asciiart"]');
|
||||
if (!btn) throw new Error('canvas toolbar button not found');
|
||||
btn.click();
|
||||
// openCanvas does layout in rAF
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
function closeCanvasIfOpen() {
|
||||
const overlay = $('#ascii-canvas-overlay');
|
||||
if (overlay?.classList.contains('open')) {
|
||||
const cancel = $('#ascii-canvas-cancel');
|
||||
cancel?.click();
|
||||
}
|
||||
}
|
||||
|
||||
function getGridText() {
|
||||
return $('#ascii-canvas-grid')?.textContent || '';
|
||||
}
|
||||
|
||||
function getGridLines() {
|
||||
return getGridText().split('\n');
|
||||
}
|
||||
|
||||
// Dispatch a mousedown at a logical (col, row) cell on the grid
|
||||
function clickCell(col, row) {
|
||||
const grid = $('#ascii-canvas-grid');
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const cols = parseInt($('#ascii-canvas-width').value);
|
||||
const rows = parseInt($('#ascii-canvas-height').value);
|
||||
const cellW = rect.width / cols;
|
||||
const cellH = rect.height / rows;
|
||||
const x = rect.left + cellW * col + cellW / 2;
|
||||
const y = rect.top + cellH * row + cellH / 2;
|
||||
grid.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: x, clientY: y }));
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0, clientX: x, clientY: y }));
|
||||
}
|
||||
|
||||
// ── Plugin Registration ──
|
||||
|
||||
describe('Canvas Plugin: Registration', () => {
|
||||
it('plugin is registered with hyprcrd', () => {
|
||||
expect(window.hyprcrd).toExist();
|
||||
const meta = window.hyprcrd.plugins.find(p => p.name === 'canvas');
|
||||
expect(meta).toExist();
|
||||
});
|
||||
|
||||
it('asciiart editor command is registered', () => {
|
||||
expect(window.hyprcrd.editor.getCommand('asciiart')).toExist();
|
||||
});
|
||||
|
||||
it('ascii fenced block handler is registered', () => {
|
||||
const handlers = window.hyprcrd.markdown.getFencedBlocks();
|
||||
const ascii = handlers.find(h => h.lang === 'ascii');
|
||||
expect(ascii).toExist();
|
||||
});
|
||||
|
||||
it('toolbar button is injected into md-toolbar', () => {
|
||||
const btn = document.querySelector('#md-toolbar [data-md="asciiart"]');
|
||||
expect(btn).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Overlay Structure ──
|
||||
|
||||
describe('Canvas Plugin: Overlay Structure', () => {
|
||||
it('overlay element exists in DOM', () => {
|
||||
expect($('#ascii-canvas-overlay')).toExist();
|
||||
});
|
||||
|
||||
it('grid, toolbar, and inputs exist', () => {
|
||||
expect($('#ascii-canvas-grid')).toExist();
|
||||
expect($('#ascii-canvas-toolbar')).toExist();
|
||||
expect($('#ascii-char-input')).toExist();
|
||||
expect($('#ascii-canvas-width')).toExist();
|
||||
expect($('#ascii-canvas-height')).toExist();
|
||||
});
|
||||
|
||||
it('all 7 tool buttons exist', () => {
|
||||
const tools = ['brush', 'eraser', 'line', 'rect', 'circle', 'fill', 'text'];
|
||||
for (const t of tools) {
|
||||
const btn = document.querySelector(`[data-tool="${t}"]`);
|
||||
expect(btn).toExist();
|
||||
}
|
||||
});
|
||||
|
||||
it('action buttons exist (ok, cancel, clear, resize)', () => {
|
||||
expect($('#ascii-canvas-ok')).toExist();
|
||||
expect($('#ascii-canvas-cancel')).toExist();
|
||||
expect($('#ascii-canvas-clear')).toExist();
|
||||
expect($('#ascii-canvas-resize')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Open / Close ──
|
||||
|
||||
describe('Canvas Plugin: Open & Close', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('overlay is hidden initially', () => {
|
||||
const overlay = $('#ascii-canvas-overlay');
|
||||
expect(overlay.classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('opens via toolbar button', async () => {
|
||||
await openCanvas();
|
||||
expect($('#ascii-canvas-overlay').classList.contains('open')).toBe(true);
|
||||
});
|
||||
|
||||
it('cancel button closes overlay', async () => {
|
||||
await openCanvas();
|
||||
click('#ascii-canvas-cancel');
|
||||
await sleep(50);
|
||||
expect($('#ascii-canvas-overlay').classList.contains('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('overlay is filled with character grid on open', async () => {
|
||||
await openCanvas();
|
||||
const lines = getGridLines();
|
||||
const cols = parseInt($('#ascii-canvas-width').value);
|
||||
const rows = parseInt($('#ascii-canvas-height').value);
|
||||
expect(lines.length).toBe(rows);
|
||||
expect(lines[0].length).toBe(cols);
|
||||
});
|
||||
|
||||
it('palette is populated with character buttons', async () => {
|
||||
await openCanvas();
|
||||
const buttons = document.querySelectorAll('#ascii-char-palette .ascii-char-btn');
|
||||
expect(buttons.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool Selection ──
|
||||
|
||||
describe('Canvas Plugin: Tool Selection', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('brush is the default active tool', async () => {
|
||||
await openCanvas();
|
||||
const brushBtn = document.querySelector('[data-tool="brush"]');
|
||||
expect(brushBtn.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking a tool button activates it and deactivates others', async () => {
|
||||
await openCanvas();
|
||||
click(document.querySelector('[data-tool="line"]'));
|
||||
await sleep(20);
|
||||
expect(document.querySelector('[data-tool="line"]').classList.contains('active')).toBe(true);
|
||||
expect(document.querySelector('[data-tool="brush"]').classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('keyboard shortcut switches tool', async () => {
|
||||
await openCanvas();
|
||||
const overlay = $('#ascii-canvas-overlay');
|
||||
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'r', bubbles: true }));
|
||||
await sleep(20);
|
||||
expect(document.querySelector('[data-tool="rect"]').classList.contains('active')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Drawing ──
|
||||
|
||||
describe('Canvas Plugin: Drawing', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('brush click sets a character at the clicked cell', async () => {
|
||||
await openCanvas();
|
||||
// Use a known character
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = 'X';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
|
||||
clickCell(5, 3);
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
expect(lines[3][5]).toBe('X');
|
||||
});
|
||||
|
||||
it('eraser replaces character with space', async () => {
|
||||
await openCanvas();
|
||||
// Draw first
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = '#';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
clickCell(2, 2);
|
||||
await sleep(20);
|
||||
expect(getGridLines()[2][2]).toBe('#');
|
||||
|
||||
// Switch to eraser and click same cell
|
||||
click(document.querySelector('[data-tool="eraser"]'));
|
||||
await sleep(20);
|
||||
clickCell(2, 2);
|
||||
await sleep(20);
|
||||
expect(getGridLines()[2][2]).toBe(' ');
|
||||
});
|
||||
|
||||
it('clear button empties the grid', async () => {
|
||||
await openCanvas();
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = '#';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
clickCell(1, 1);
|
||||
clickCell(2, 2);
|
||||
clickCell(3, 3);
|
||||
await sleep(20);
|
||||
|
||||
click('#ascii-canvas-clear');
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
// All chars should be space
|
||||
const allSpace = lines.every(l => /^ *$/.test(l));
|
||||
expect(allSpace).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Character Selection ──
|
||||
|
||||
describe('Canvas Plugin: Character Selection', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('clicking palette button updates active char', async () => {
|
||||
await openCanvas();
|
||||
const targetBtn = document.querySelector('#ascii-char-palette .ascii-char-btn');
|
||||
const ch = targetBtn.textContent;
|
||||
click(targetBtn);
|
||||
await sleep(20);
|
||||
|
||||
// Draw with that char
|
||||
clickCell(0, 0);
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
expect(lines[0][0]).toBe(ch);
|
||||
});
|
||||
|
||||
it('typing in char input changes active character', async () => {
|
||||
await openCanvas();
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = '@';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
|
||||
clickCell(4, 4);
|
||||
await sleep(20);
|
||||
|
||||
const lines = getGridLines();
|
||||
expect(lines[4][4]).toBe('@');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Insert into Editor ──
|
||||
|
||||
describe('Canvas Plugin: Insert', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('OK button inserts ```ascii block into editor body', async () => {
|
||||
await openCanvas();
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = 'Z';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
|
||||
clickCell(0, 0);
|
||||
clickCell(1, 0);
|
||||
clickCell(2, 0);
|
||||
await sleep(20);
|
||||
|
||||
click('#ascii-canvas-ok');
|
||||
await sleep(50);
|
||||
|
||||
const body = $('#editor-body');
|
||||
expect(body.value).toContain('```ascii');
|
||||
expect(body.value).toContain('ZZZ');
|
||||
expect(body.value).toContain('```\n');
|
||||
});
|
||||
|
||||
it('cancel does NOT insert anything', async () => {
|
||||
await openEditor();
|
||||
const body = $('#editor-body');
|
||||
const beforeLen = body.value.length;
|
||||
|
||||
const btn = document.querySelector('[data-md="asciiart"]');
|
||||
btn.click();
|
||||
await sleep(100);
|
||||
|
||||
const charInput = $('#ascii-char-input');
|
||||
charInput.value = 'Q';
|
||||
charInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(20);
|
||||
clickCell(0, 0);
|
||||
await sleep(20);
|
||||
|
||||
click('#ascii-canvas-cancel');
|
||||
await sleep(50);
|
||||
|
||||
expect(body.value.length).toBe(beforeLen);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit existing block ──
|
||||
|
||||
describe('Canvas Plugin: Edit Existing Block', () => {
|
||||
afterEach(async () => {
|
||||
closeCanvasIfOpen();
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it('cursor inside ```ascii block loads existing content', async () => {
|
||||
await openEditor();
|
||||
const body = $('#editor-body');
|
||||
body.value = '```ascii\n###\n###\n```';
|
||||
body.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// Place cursor inside the block content
|
||||
const insidePos = body.value.indexOf('###') + 1;
|
||||
body.selectionStart = body.selectionEnd = insidePos;
|
||||
|
||||
const btn = document.querySelector('[data-md="asciiart"]');
|
||||
btn.click();
|
||||
await sleep(150);
|
||||
|
||||
const lines = getGridLines();
|
||||
// First two lines should contain the existing # chars
|
||||
expect(lines[0].startsWith('###')).toBe(true);
|
||||
expect(lines[1].startsWith('###')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Markdown Rendering ──
|
||||
|
||||
describe('Canvas Plugin: Markdown Rendering', () => {
|
||||
it('```ascii fenced block renders as <pre class="ascii-art">', async () => {
|
||||
// Use the registered handler directly via the registry
|
||||
const handlers = window.hyprcrd.markdown.getFencedBlocks();
|
||||
const ascii = handlers.find(h => h.lang === 'ascii');
|
||||
expect(ascii).toExist();
|
||||
const html = ascii.handler('###\n###\n###', { esc: window.hyprcrd.util.esc });
|
||||
expect(html).toContain('<pre class="ascii-art">');
|
||||
expect(html).toContain('###');
|
||||
});
|
||||
|
||||
it('renderMd produces ascii-art pre when given an ```ascii block', async () => {
|
||||
const { renderMd } = await import('../../js/modules/markdown.js');
|
||||
const html = renderMd('```ascii\n***\n***\n```');
|
||||
expect(html).toContain('ascii-art');
|
||||
expect(html).toContain('***');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Card: Tag Operations', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js';
|
||||
import { showDialog } from '../modules/dialog.js';
|
||||
import { showDialog } from '../../js/modules/dialog.js';
|
||||
|
||||
describe('Datepicker: Rendering', () => {
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Favorites Bucket', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js';
|
||||
import { state, parseTags, sortedCards } from '../modules/state.js';
|
||||
import { state, parseTags, sortedCards } from '../../js/modules/state.js';
|
||||
|
||||
describe('Filtering: Tag AND Logic', () => {
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('Image Fullscreen Viewer', () => {
|
||||
|
||||
// Check that images in detail would get fullscreen handler
|
||||
// The overlay is created lazily, verify the create function structure
|
||||
const { bindDetailImageFullscreen } = await import('../modules/detail.js');
|
||||
const { bindDetailImageFullscreen } = await import('../../js/modules/detail.js');
|
||||
expect(typeof bindDetailImageFullscreen).toBe('function');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// ── Tests Plugin: conditional loader ──
|
||||
//
|
||||
// The test runner is heavy (40+ test files), so we only load it when:
|
||||
// - URL contains ?test query param → auto-run
|
||||
// - Ctrl+Shift+T pressed → manual open
|
||||
//
|
||||
// Tests are completely optional — without this plugin the app has no
|
||||
// test runner code loaded at all.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
|
||||
// Auto-load if ?test in query string
|
||||
if (new URLSearchParams(location.search).has('test')) {
|
||||
import('./tests.js');
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Ctrl+Shift+T → open runner
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||
e.preventDefault();
|
||||
import('./tests.js').then(() =>
|
||||
import('./runner.js').then(m => m.open?.())
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
hyprcrd?.registerPlugin?.({
|
||||
name: 'tests',
|
||||
version: '1.0.0',
|
||||
description: 'UI + state test runner',
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { renderMd } from '../modules/markdown.js';
|
||||
import { renderMd } from '../../js/modules/markdown.js';
|
||||
|
||||
describe('Markdown: Blank Lines', () => {
|
||||
it('image after blank lines renders', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { renderMd, formatTodoItems, stripMd } from '../modules/markdown.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { renderMd, formatTodoItems, stripMd } from '../../js/modules/markdown.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Markdown: Basic Rendering', () => {
|
||||
it('renders paragraph', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, sleep, closeAll } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Multi-Select', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "tests",
|
||||
"version": "1.0.0",
|
||||
"description": "UI + state test runner. Loads only when ?test query param is present or Ctrl+Shift+T is pressed.",
|
||||
"frontend": {
|
||||
"js": ["loader.js"],
|
||||
"css": [],
|
||||
"html": []
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
// RRULE is set by CalDAV sync, not via PATCH. Test the data model and existing cards.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Panel is hidden during test run so interactions are visible.
|
||||
// Backup is created before and auto-restored after every run.
|
||||
|
||||
import { getCsrf } from '../modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../modules/caldav.js';
|
||||
import { getCsrf } from '../../js/modules/util.js';
|
||||
import { stopBackgroundSync, startBackgroundSync } from '../../js/modules/caldav.js';
|
||||
|
||||
const suites = [];
|
||||
let currentSuite = null;
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('Settings: Open/Close', () => {
|
||||
expect($('#settings-overlay')).toBeVisible();
|
||||
});
|
||||
|
||||
it('has all 7 tabs', async () => {
|
||||
it('has all 8 tabs', async () => {
|
||||
click('#settings-btn');
|
||||
await sleep(100);
|
||||
const names = $$('.settings-tab').map(t => t.dataset.tab);
|
||||
expect(names.length).toBe(7);
|
||||
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications']) {
|
||||
expect(names.length).toBe(8);
|
||||
for (const tab of ['branding', 'account', 'theme', 'caldav', 'relay', 'behavior', 'notifications', 'plugins']) {
|
||||
expect(names).toContain(tab);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, csrf } from './runner.js';
|
||||
import { state, sortedCards } from '../modules/state.js';
|
||||
import { state, sortedCards } from '../../js/modules/state.js';
|
||||
|
||||
describe('Sorting: All Modes', () => {
|
||||
it('manual sort by position', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from './runner.js';
|
||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../modules/state.js';
|
||||
import { state, parseTags, sortedCards, getChildren, getGroupCards, getRecent, markRecent, getFavorites, toggleFavorite, isFavorite } from '../../js/modules/state.js';
|
||||
|
||||
describe('State: parseTags', () => {
|
||||
it('splits comma-separated tags', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import './editor-features.test.js';
|
||||
import './editor-save.test.js';
|
||||
import './editor-toolbar.test.js';
|
||||
import './line-numbers.test.js';
|
||||
import './canvas.test.js';
|
||||
import './snippets.test.js';
|
||||
import './image-fullscreen.test.js';
|
||||
import './markdown.test.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js';
|
||||
import { state } from '../modules/state.js';
|
||||
import { state } from '../../js/modules/state.js';
|
||||
|
||||
describe('Toolbar: Filter Toggles', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('Undo: Card Delete', () => {
|
||||
// The child might be inside a group view — let's just test via the undo module directly
|
||||
|
||||
// Import undo and test directly
|
||||
const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../modules/undo.js');
|
||||
const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../../js/modules/undo.js');
|
||||
const snap = { ...child, parent_id: parent.id };
|
||||
pushCardDelete('test-del', snap);
|
||||
await fetch(`api/cards/${child.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
||||
@@ -317,7 +317,7 @@ describe('Undo: Multiple Undos', () => {
|
||||
|
||||
describe('Undo: No-Op When Empty', () => {
|
||||
it('Ctrl+Z with empty stack does nothing', async () => {
|
||||
const { canUndo } = await import('../modules/undo.js');
|
||||
const { canUndo } = await import('../../js/modules/undo.js');
|
||||
// Clear stack by undoing everything
|
||||
while (canUndo()) {
|
||||
ctrlZ();
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PluginManager — Plugin discovery, loading, and asset rendering.
|
||||
*
|
||||
* Plugins live in /public/plugins/<name>/ and must have a plugin.json manifest:
|
||||
* {
|
||||
* "name": "canvas",
|
||||
* "version": "1.0.0",
|
||||
* "description": "ASCII art canvas editor",
|
||||
* "frontend": {
|
||||
* "js": ["canvas.js"],
|
||||
* "css": ["canvas.css"],
|
||||
* "html": ["canvas.html"]
|
||||
* },
|
||||
* "backend": "backend.php" // optional
|
||||
* }
|
||||
*
|
||||
* Plugins are entirely optional — the core works fine without any.
|
||||
*/
|
||||
class PluginManager {
|
||||
private array $plugins = [];
|
||||
private array $slots = [];
|
||||
private array $disabled = []; // names of plugins the user has disabled
|
||||
private ?string $stateFile = null; // path to plugins.json
|
||||
private static ?PluginManager $instance = null;
|
||||
|
||||
public static function instance(): self {
|
||||
if (self::$instance === null) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/** Scan a directory for plugins. Each subdirectory with a plugin.json is registered. */
|
||||
public function scan(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
$entries = @scandir($dir);
|
||||
if (!$entries) return;
|
||||
sort($entries);
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
$pluginDir = $dir . '/' . $entry;
|
||||
if (!is_dir($pluginDir)) continue;
|
||||
$manifestPath = $pluginDir . '/plugin.json';
|
||||
if (!file_exists($manifestPath)) continue;
|
||||
$raw = @file_get_contents($manifestPath);
|
||||
$manifest = $raw ? json_decode($raw, true) : null;
|
||||
if (!is_array($manifest)) continue;
|
||||
$name = $manifest['name'] ?? $entry;
|
||||
$this->plugins[$name] = [
|
||||
'dir' => $pluginDir,
|
||||
'slug' => $entry,
|
||||
'manifest' => $manifest,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** After scan() and loadDisabled(), require_once the backend.php of every ENABLED plugin. */
|
||||
public function loadBackends(): void {
|
||||
foreach ($this->plugins as $name => $p) {
|
||||
if (!$this->isEnabled($name)) continue;
|
||||
$backend = $p['manifest']['backend'] ?? null;
|
||||
if (!$backend) continue;
|
||||
$backendPath = $p['dir'] . '/' . $backend;
|
||||
if (file_exists($backendPath)) require_once $backendPath;
|
||||
}
|
||||
}
|
||||
|
||||
public function plugins(): array {
|
||||
return $this->plugins;
|
||||
}
|
||||
|
||||
/** Load the disabled-plugins state file. Creates it lazily on first write. */
|
||||
public function loadDisabled(string $stateFile): void {
|
||||
$this->stateFile = $stateFile;
|
||||
if (!file_exists($stateFile)) { $this->disabled = []; return; }
|
||||
$raw = @file_get_contents($stateFile);
|
||||
if (!$raw) { $this->disabled = []; return; }
|
||||
$data = json_decode($raw, true);
|
||||
$list = is_array($data) ? ($data['disabled'] ?? []) : [];
|
||||
$this->disabled = array_values(array_filter($list, 'is_string'));
|
||||
}
|
||||
|
||||
/** Persist the disabled list to the state file. Returns true on success. */
|
||||
public function setDisabled(array $names): bool {
|
||||
if ($this->stateFile === null) return false;
|
||||
$clean = array_values(array_unique(array_filter($names, 'is_string')));
|
||||
$this->disabled = $clean;
|
||||
$dir = dirname($this->stateFile);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
||||
$payload = json_encode(['disabled' => $clean], JSON_PRETTY_PRINT);
|
||||
return @file_put_contents($this->stateFile, $payload) !== false;
|
||||
}
|
||||
|
||||
public function isEnabled(string $name): bool {
|
||||
return !in_array($name, $this->disabled, true);
|
||||
}
|
||||
|
||||
public function disabledList(): array {
|
||||
return $this->disabled;
|
||||
}
|
||||
|
||||
/** Public-friendly plugin list for the API: [{name, version, description, enabled}]. */
|
||||
public function pluginsForApi(): array {
|
||||
$out = [];
|
||||
foreach ($this->plugins as $name => $p) {
|
||||
$m = $p['manifest'];
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'version' => $m['version'] ?? '',
|
||||
'description' => $m['description'] ?? '',
|
||||
'enabled' => $this->isEnabled($name),
|
||||
'hasBackend' => !empty($m['backend']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Append HTML content to a named slot. Backend plugins use this. */
|
||||
public function addToSlot(string $slot, string $html): void {
|
||||
$this->slots[$slot][] = $html;
|
||||
}
|
||||
|
||||
/** Render a slot's accumulated HTML. */
|
||||
public function slot(string $slot): string {
|
||||
return implode("\n", $this->slots[$slot] ?? []);
|
||||
}
|
||||
|
||||
/** Render <link> tags for enabled plugin CSS files. */
|
||||
public function cssLinks(string $baseUrl = 'plugins'): string {
|
||||
$out = [];
|
||||
foreach ($this->plugins as $name => $p) {
|
||||
if (!$this->isEnabled($name)) continue;
|
||||
$cssList = $p['manifest']['frontend']['css'] ?? [];
|
||||
foreach ($cssList as $css) {
|
||||
$out[] = '<link rel="stylesheet" href="' . $baseUrl . '/' . $p['slug'] . '/' . $css . '?v=' . time() . '">';
|
||||
}
|
||||
}
|
||||
return implode("\n ", $out);
|
||||
}
|
||||
|
||||
/** Render inlined HTML fragments from enabled plugins (for overlays etc.). */
|
||||
public function htmlFragments(): string {
|
||||
$out = [];
|
||||
foreach ($this->plugins as $name => $p) {
|
||||
if (!$this->isEnabled($name)) continue;
|
||||
$htmlList = $p['manifest']['frontend']['html'] ?? [];
|
||||
foreach ($htmlList as $htmlFile) {
|
||||
$path = $p['dir'] . '/' . $htmlFile;
|
||||
if (file_exists($path)) {
|
||||
$out[] = "<!-- plugin:{$p['slug']}:{$htmlFile} -->\n" . file_get_contents($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return implode("\n", $out);
|
||||
}
|
||||
|
||||
/** Render <script type="module"> tags for enabled plugin JS entry points. */
|
||||
public function scriptTags(string $baseUrl = 'plugins'): string {
|
||||
$out = [];
|
||||
foreach ($this->plugins as $name => $p) {
|
||||
if (!$this->isEnabled($name)) continue;
|
||||
$jsList = $p['manifest']['frontend']['js'] ?? [];
|
||||
foreach ($jsList as $js) {
|
||||
$out[] = '<script type="module" src="' . $baseUrl . '/' . $p['slug'] . '/' . $js . '?v=' . time() . '"></script>';
|
||||
}
|
||||
}
|
||||
return implode("\n ", $out);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ if (function_exists('opcache_invalidate')) {
|
||||
|
||||
require_once $configPath;
|
||||
require_once __DIR__ . '/Database.php';
|
||||
require_once __DIR__ . '/PluginManager.php';
|
||||
|
||||
// Ensure data directory is writable
|
||||
$dataDir = dirname(DB_PATH);
|
||||
@@ -16,3 +17,9 @@ if (!is_writable($dataDir) && is_dir($dataDir)) {
|
||||
echo 'Data directory is not writable';
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Discover plugins (each subdir of public/plugins/ with a plugin.json)
|
||||
$pluginManager = PluginManager::instance();
|
||||
$pluginManager->scan(__DIR__ . '/../public/plugins');
|
||||
$pluginManager->loadDisabled(dirname(DB_PATH) . '/plugins.json');
|
||||
$pluginManager->loadBackends(); // require_once enabled backend.php files
|
||||
Reference in new issue
Block a user