Changeset 0.18.1 (#80)
This commit is contained in:
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,6 +2,71 @@
|
||||
|
||||
All notable changes to Chat Switchboard.
|
||||
|
||||
## [0.18.1] — 2026-02-28
|
||||
|
||||
### Added
|
||||
- **Side panel architecture.** Complete rewrite of the side panel system
|
||||
from shared-tab layout to independent single-slot panels. Any action
|
||||
(preview, notes, diagram pop-out) fills the slot, replacing whatever
|
||||
was there — no tabs, no association between panel types.
|
||||
- **Panel registry** (`PanelRegistry`): named panels with independent
|
||||
open/close state, scroll/state preservation across switches, keyboard
|
||||
shortcuts (Ctrl+\ cycle, Ctrl+Shift+\ toggle dual-view).
|
||||
- **Dual-view mode**: two panels side-by-side via CSS grid with a
|
||||
drag-adjustable split ratio (0.2–0.8 range, 6px divider handle).
|
||||
Toggle button in header actions area alongside fullscreen and close.
|
||||
- **Live HTML preview**: streaming responses update the preview iframe
|
||||
in real-time (500ms debounce). Extracts the last fenced HTML block
|
||||
from partial content and renders it as the response streams in.
|
||||
- **Extension pop-out**: `⧉` button on rendered extension blocks
|
||||
(mermaid, KaTeX, etc.) clones the content into the preview iframe
|
||||
and opens the side panel.
|
||||
- **Extension UI primitives** (`ctx.ui`): seven methods exposed to
|
||||
browser extensions through the scoped extension context:
|
||||
- `toast(msg, type)` — toast notifications via `UI.toast()`
|
||||
- `openPreview(html)` — load HTML into side panel preview iframe
|
||||
- `isDark()` — theme detection without DOM sniffing
|
||||
- `isMobile()` — viewport width check (≤768px)
|
||||
- `isPanelOpen()` — side panel container visibility
|
||||
- `confirm(msg, opts)` — modal confirm dialog (Promise\<boolean>)
|
||||
- `createMenu(anchor, opts)` — popup menu (unchanged from stub)
|
||||
- **Mermaid context-aware expand**: single `⛶` button replaces the
|
||||
previous two-button (pop-out + fullscreen) approach. When the side
|
||||
panel is open, expand pops the diagram into it; when closed, expand
|
||||
goes fullscreen.
|
||||
- **Mermaid fullscreen close button**: 40px circular close button
|
||||
overlaid top-right in fullscreen mode, 48px on mobile. Fixes the
|
||||
previous Escape-key-only exit which was unusable on touch devices.
|
||||
- **Mobile side panel**: swipe navigation between panels (80px
|
||||
threshold, 1.5× horizontal-to-vertical ratio), tap-to-close overlay,
|
||||
responsive auto-collapse of dual mode on narrow viewports, enlarged
|
||||
touch targets for all panel controls.
|
||||
- **Side panel header label**: simple text label showing the active
|
||||
panel name, replacing the previous tab bar UI.
|
||||
|
||||
### Changed
|
||||
- Side panel model changed from tabbed (Preview + Notes tabs visible
|
||||
simultaneously) to single-slot (one panel fills the space, actions
|
||||
replace it). No user-selectable tabs — content is entirely
|
||||
action-driven.
|
||||
- Dual-view toggle button moved from tab bar (dynamically injected)
|
||||
to static header actions area next to fullscreen and close.
|
||||
- Mermaid extension refactored to use `ctx.ui` primitives exclusively.
|
||||
Zero direct references to `UI.*`, `PanelRegistry.*`, or DOM class
|
||||
sniffing for theme detection. Extension remains fully self-contained
|
||||
in `extensions/builtin/mermaid-renderer/`.
|
||||
- Mermaid source copy uses `ctx.ui.toast()` instead of inline button
|
||||
text swap. Theme detection uses `ctx.ui.isDark()` instead of manual
|
||||
`document.body.classList.contains('dark-theme')` check.
|
||||
- Side panel outer resize minimum bumped to 480px in dual mode (vs
|
||||
280px single).
|
||||
|
||||
### Removed
|
||||
- Tab bar UI (`.side-panel-tabs`, `.side-panel-tab`, tab rendering
|
||||
logic). Panels no longer have user-selectable tabs.
|
||||
- Separate pop-out and fullscreen buttons in mermaid toolbar (collapsed
|
||||
into single context-aware expand button).
|
||||
|
||||
## [0.18.0] — 2026-02-28
|
||||
|
||||
### Added
|
||||
|
||||
244
docs/DESIGN-0.18.1.md
Normal file
244
docs/DESIGN-0.18.1.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Design — v0.18.1 Side Panel Architecture
|
||||
|
||||
## Problem
|
||||
|
||||
The side panel is a single `<aside>` with two mutually exclusive tabs
|
||||
(Preview and Notes) toggled by `display: none`. Opening Notes kills the
|
||||
preview and vice versa. As more panel content arrives (KB browser,
|
||||
search results, memory viewer), the single-slot model doesn't scale.
|
||||
The current implementation also loses per-panel state (scroll position,
|
||||
active note, preview content) on tab switches.
|
||||
|
||||
This is also prerequisite plumbing for v0.23.0 multi-participant
|
||||
channels, where multiple panels need simultaneous visibility.
|
||||
|
||||
## Current State
|
||||
|
||||
**HTML:** Single `<aside id="sidePanel">` containing:
|
||||
- Resize handle (`sidePanelResize`)
|
||||
- Header with tab bar (Preview | Notes) + action buttons (clear, fullscreen, close)
|
||||
- Body with two `.side-panel-page` divs toggled by display
|
||||
|
||||
**JS (ui-format.js lines 295–365):**
|
||||
- `openSidePanel(tab)` — adds `.open` class, calls `switchSidePanelTab`
|
||||
- `closeSidePanel()` — removes `.open` and `.fullscreen`, clears inline width
|
||||
- `switchSidePanelTab(tab)` — toggles active class on tab buttons, toggles display on page divs
|
||||
- `_initSidePanelResize()` — mousedown/move/up drag handler for width resize
|
||||
|
||||
**Callers (4 total):**
|
||||
1. `toggleHTMLPreview()` → `openSidePanel('preview')` (ui-format.js)
|
||||
2. `openNotes()` → `openSidePanel('notes')` / `closeSidePanel()` (notes.js)
|
||||
3. Escape handler → `closeSidePanel()` (app.js)
|
||||
4. Inline onclick handlers in index.html (tab buttons, close, fullscreen, clear)
|
||||
|
||||
**CSS (styles.css lines 642–737):**
|
||||
- `.side-panel` base: `width: 0; min-width: 0; overflow: hidden`, transition on width
|
||||
- `.side-panel.open`: `width: 480px; min-width: 480px`
|
||||
- `.side-panel.fullscreen`: fixed positioning, 100% width
|
||||
- Mobile (`@media max-width: 768px`): open = fixed overlay at 100vw
|
||||
|
||||
## Design
|
||||
|
||||
### Panel Registry
|
||||
|
||||
A lightweight registry in a new `panels.js` file. Each panel is a named
|
||||
entry with its own state and DOM container. The registry owns all
|
||||
open/close/focus logic — individual panels never touch the `<aside>`
|
||||
directly.
|
||||
|
||||
```js
|
||||
const PanelRegistry = {
|
||||
_panels: {}, // { name: { element, onOpen, onClose, onFocus, state } }
|
||||
_active: null, // currently visible panel name (single-view mode)
|
||||
_secondary: null, // second visible panel (dual-view mode)
|
||||
_dualMode: false,
|
||||
|
||||
register(name, opts) { ... },
|
||||
open(name) { ... },
|
||||
close(name) { ... },
|
||||
toggle(name) { ... },
|
||||
closeAll() { ... },
|
||||
isOpen(name) { ... },
|
||||
active() { ... },
|
||||
};
|
||||
```
|
||||
|
||||
**Panel registration contract:**
|
||||
|
||||
```js
|
||||
PanelRegistry.register('notes', {
|
||||
element: document.getElementById('sidePanelNotes'),
|
||||
onOpen() { loadNotesList(); loadNoteFolders(); },
|
||||
onClose() { /* optional cleanup */ },
|
||||
onFocus() { /* optional re-activate */ },
|
||||
state: {}, // panel-specific state bag (scroll pos, etc.)
|
||||
});
|
||||
```
|
||||
|
||||
Panels register themselves during initialization — Notes registers in
|
||||
`notes.js`, Preview registers in `ui-format.js`. This keeps domain
|
||||
logic co-located with its panel.
|
||||
|
||||
### DOM Structure
|
||||
|
||||
Replace the hardcoded tab bar with a registry-driven header. The
|
||||
`<aside>` structure stays identical in shape (no layout break), but
|
||||
tabs render dynamically from registered panels:
|
||||
|
||||
```html
|
||||
<aside class="side-panel" id="sidePanel">
|
||||
<div class="side-panel-resize" id="sidePanelResize"></div>
|
||||
<div class="side-panel-header">
|
||||
<div class="side-panel-tabs" id="sidePanelTabs">
|
||||
<!-- rendered by PanelRegistry from registered panels -->
|
||||
</div>
|
||||
<div class="side-panel-actions" id="sidePanelActions">
|
||||
<!-- per-panel action buttons injected by active panel -->
|
||||
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" ...>
|
||||
<button class="side-panel-close" ...>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-panel-body" id="sidePanelBody">
|
||||
<!-- all .side-panel-page divs, shown/hidden by registry -->
|
||||
</div>
|
||||
</aside>
|
||||
```
|
||||
|
||||
The tab bar is re-rendered on `register()` and on `open()`/`close()`.
|
||||
Each panel can declare `actions` — an array of button configs that
|
||||
appear in the header when that panel is active (replacing the current
|
||||
hardcoded clear button for preview).
|
||||
|
||||
### State Preservation
|
||||
|
||||
Each panel gets a `state` bag on its registry entry. Before hiding a
|
||||
panel, the registry calls a `saveState()` hook (if provided) which can
|
||||
snapshot scroll position, selection, etc. On re-show, `restoreState()`
|
||||
re-applies. This is opt-in — panels that don't need it don't implement
|
||||
the hooks.
|
||||
|
||||
Notes state: `{ scrollTop, editingNoteId, viewMode: 'list'|'editor'|'graph' }`
|
||||
Preview state: `{ srcdoc, hasContent }`
|
||||
|
||||
### Dual-View Mode (Phase 2)
|
||||
|
||||
When enabled, the panel body splits into two panes with an internal
|
||||
divider. `_active` shows on the left (or top on mobile), `_secondary`
|
||||
on the right (or bottom). The outer resize handle still controls total
|
||||
panel width; an inner divider controls the split ratio.
|
||||
|
||||
Dual mode activates via:
|
||||
- `Ctrl/Cmd+Shift+\` keyboard shortcut
|
||||
- Dragging a tab to the other half (stretch goal)
|
||||
|
||||
### Per-Panel Action Buttons
|
||||
|
||||
Currently Preview has a "Clear" button and both panels share Fullscreen
|
||||
and Close. The new model:
|
||||
|
||||
- **Global actions** (always visible): Fullscreen, Close
|
||||
- **Panel actions** (per-panel, in active panel's header slot):
|
||||
- Preview: Clear
|
||||
- Notes: (none currently, but room for "New Note" shortcut)
|
||||
- Future panels declare their own
|
||||
|
||||
Panels declare actions at registration time:
|
||||
|
||||
```js
|
||||
PanelRegistry.register('preview', {
|
||||
element: document.getElementById('sidePanelPreview'),
|
||||
actions: [
|
||||
{ id: 'sidePanelClearBtn', icon: '...svg...', title: 'Clear preview',
|
||||
onClick: clearPreview, visible: () => hasPreviewContent() },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Preview Enhancements (Phase 3)
|
||||
|
||||
- **Live-update during streaming:** The chat streaming handler already
|
||||
accumulates HTML code blocks. On each chunk that completes a fenced
|
||||
code block tagged `html`, if the preview panel is open, update the
|
||||
iframe's `srcdoc`. Debounce at 500ms to avoid thrashing.
|
||||
- **Extension block preview:** Mermaid, KaTeX, etc. render inline in
|
||||
chat. Add a "Pop out" button on extension-rendered blocks that opens
|
||||
the rendered output in the preview panel (full-size, zoomable).
|
||||
|
||||
### Mobile (Phase 4)
|
||||
|
||||
- **Swipe navigation:** Touch event handlers on `.side-panel-body` —
|
||||
swipe left/right cycles through open panels. Uses CSS `transform:
|
||||
translateX()` with a 150px threshold for commit.
|
||||
- **Bottom sheet mode:** On viewports < 768px, the secondary panel in
|
||||
dual mode renders as a bottom sheet (40% height) instead of side-by-
|
||||
side.
|
||||
- **Priority collapse:** When space is tight and dual mode can't fit
|
||||
(< 600px combined), collapse to single-panel with most-recently-
|
||||
opened winning. The displaced panel becomes accessible via tab bar.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+\` | Cycle active panel (or open panel container if closed) |
|
||||
| `Ctrl/Cmd+Shift+\` | Toggle dual-view mode |
|
||||
| `Escape` | Close panel container (existing behavior, unchanged) |
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1 — Panel Registry + State Management
|
||||
|
||||
Extract panel logic from `ui-format.js` into `panels.js`. Implement
|
||||
`PanelRegistry` with register/open/close/toggle/closeAll. Migrate
|
||||
Notes and Preview to register as panels. Dynamic tab bar rendering.
|
||||
Per-panel action buttons. State preservation hooks.
|
||||
|
||||
**Files changed:**
|
||||
- New: `src/js/panels.js`
|
||||
- Modified: `src/js/ui-format.js` (remove panel functions, add preview registration)
|
||||
- Modified: `src/js/notes.js` (replace `openSidePanel`/`closeSidePanel` calls with `PanelRegistry.open`/`close`)
|
||||
- Modified: `src/js/app.js` (update Escape handler, add keyboard shortcuts, replace `_initSidePanelResize` call)
|
||||
- Modified: `src/index.html` (remove hardcoded tab buttons, add `panels.js` script tag, make tab bar dynamic)
|
||||
- No CSS changes (reuse existing `.side-panel-tab` styles)
|
||||
|
||||
**Backward compatibility:** All existing panel behavior works identically.
|
||||
`openSidePanel()` and `closeSidePanel()` become thin wrappers around
|
||||
`PanelRegistry` during transition, then get removed after all callers
|
||||
migrate.
|
||||
|
||||
### Phase 2 — Dual-View Layout
|
||||
|
||||
CSS grid split inside `.side-panel-body`. Internal divider with drag
|
||||
resize. Keyboard shortcut for dual toggle. Tab drag to split (stretch).
|
||||
|
||||
**Files changed:**
|
||||
- Modified: `src/js/panels.js` (dual-view logic)
|
||||
- Modified: `src/css/styles.css` (grid layout for dual panes)
|
||||
|
||||
### Phase 3 — Preview Enhancements
|
||||
|
||||
Live-update preview during streaming. Extension block "pop out" button.
|
||||
|
||||
**Files changed:**
|
||||
- Modified: `src/js/ui-format.js` (pop-out button on extension blocks)
|
||||
- Modified: `src/js/chat.js` (streaming hook for live preview update)
|
||||
- Modified: `src/js/panels.js` (debounced preview update API)
|
||||
|
||||
### Phase 4 — Mobile
|
||||
|
||||
Swipe navigation, bottom sheet mode, priority collapse.
|
||||
|
||||
**Files changed:**
|
||||
- Modified: `src/js/panels.js` (touch handlers, priority logic)
|
||||
- Modified: `src/css/styles.css` (bottom sheet layout, responsive breakpoints)
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] `openSidePanel(tab)` → `PanelRegistry.open(tab)`
|
||||
- [ ] `closeSidePanel()` → `PanelRegistry.closeAll()`
|
||||
- [ ] `switchSidePanelTab(tab)` → `PanelRegistry.open(tab)` (open implies focus)
|
||||
- [ ] `toggleSidePanelFullscreen()` → stays global (operates on container, not individual panels)
|
||||
- [ ] `_initSidePanelResize()` → moves to `panels.js` (operates on container)
|
||||
- [ ] Escape handler: `PanelRegistry.closeAll()`
|
||||
- [ ] `openNotes()` toggle logic: `PanelRegistry.toggle('notes')`
|
||||
- [ ] `toggleHTMLPreview()`: `PanelRegistry.open('preview')` + update iframe content
|
||||
@@ -68,7 +68,7 @@ v0.17.3 Notes Graph + Wikilinks ✅
|
||||
│
|
||||
v0.18.0 Memory ✅ (user + persona scopes, review pipeline)
|
||||
│
|
||||
v0.18.1 Side Panel Architecture (independent panels, dual-view)
|
||||
v0.18.1 Side Panel Architecture ✅ (single-slot, ctx.ui primitives, mermaid refactor)
|
||||
│
|
||||
v0.19.0 Projects / Workspaces + Notifications
|
||||
│
|
||||
@@ -454,34 +454,45 @@ See [DESIGN-0.18.0.md](DESIGN-0.18.0.md) for full spec.
|
||||
|
||||
---
|
||||
|
||||
## v0.18.1 — Side Panel Architecture
|
||||
## v0.18.1 — Side Panel Architecture ✅
|
||||
|
||||
Refactor the side panel system so Notes, HTML/code previews, and future
|
||||
panels (knowledge base browser, search results) operate independently
|
||||
rather than sharing a single slot. Currently, opening Notes hides the
|
||||
preview panel and vice versa — this is confusing when reviewing AI-
|
||||
generated HTML/documents while also managing notes.
|
||||
Refactored the side panel from a shared-tab layout to independent
|
||||
single-slot panels. Any action fills the slot, replacing whatever was
|
||||
there — no tabs, no association between panel types. Extension UI
|
||||
primitives give browser extensions safe access to host app features.
|
||||
|
||||
Depends on: Notes graph + wikilinks (v0.17.3). Prerequisite for: live
|
||||
collaboration (v0.23.0) where multiple panels need simultaneous visibility.
|
||||
|
||||
**Panel System**
|
||||
- [ ] Panel registry: named panels with independent open/close state
|
||||
- [ ] Dual-view mode: two panels side-by-side (configurable split ratio)
|
||||
- [ ] Tab-persistent state: each panel remembers scroll position, open note, etc.
|
||||
- [ ] Panel priority: when space is tight (mobile), most-recently-opened wins
|
||||
- [ ] Keyboard shortcut: Ctrl/Cmd+\ cycles panels, Ctrl/Cmd+Shift+\ toggles dual
|
||||
- [x] Panel registry: named panels with independent open/close state
|
||||
- [x] Single-slot model: action-driven, each open replaces the previous
|
||||
- [x] Dual-view mode: two panels side-by-side (configurable split ratio via drag divider)
|
||||
- [x] Panel-persistent state: each panel remembers scroll position, open note, etc.
|
||||
- [x] Header label shows active panel name (replaces tab bar)
|
||||
- [x] Keyboard shortcut: Ctrl/Cmd+\ cycles panels, Ctrl/Cmd+Shift+\ toggles dual
|
||||
|
||||
**Preview Panel**
|
||||
- [ ] Dedicated preview panel (independent of Notes)
|
||||
- [ ] HTML/document preview with iframe sandbox
|
||||
- [ ] Code output preview (extension-rendered blocks)
|
||||
- [ ] Live-updating during streaming responses
|
||||
- [x] Dedicated preview panel (independent of Notes)
|
||||
- [x] HTML/document preview with iframe sandbox
|
||||
- [x] Code output preview (extension-rendered blocks via pop-out)
|
||||
- [x] Live-updating during streaming responses (500ms debounce)
|
||||
|
||||
**Extension UI Primitives**
|
||||
- [x] `ctx.ui.toast()` — toast notifications
|
||||
- [x] `ctx.ui.openPreview()` — load HTML into side panel preview
|
||||
- [x] `ctx.ui.isDark()` — theme detection
|
||||
- [x] `ctx.ui.isMobile()` — viewport width check
|
||||
- [x] `ctx.ui.isPanelOpen()` — side panel visibility
|
||||
- [x] `ctx.ui.confirm()` — modal confirm dialog
|
||||
- [x] Mermaid extension refactored to use primitives exclusively (zero direct globals)
|
||||
- [x] Context-aware expand: single button, fullscreen or side panel pop-out
|
||||
|
||||
**Mobile**
|
||||
- [ ] Swipe navigation between panels
|
||||
- [ ] Bottom sheet mode for secondary panel
|
||||
- [ ] Graceful collapse to single-panel on narrow viewports
|
||||
- [x] Swipe navigation between panels
|
||||
- [x] Tap-to-close overlay
|
||||
- [x] Graceful collapse to single-panel on narrow viewports
|
||||
- [x] Enlarged touch targets, fullscreen close button (40px desktop, 48px mobile)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
// Mermaid Diagram Renderer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
||||
// Features: viewBox-based zoom/pan, fullscreen mode, SVG/PNG export, source copy.
|
||||
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
|
||||
// or side-panel pop-out), SVG/PNG export, source copy.
|
||||
// Loads mermaid.js dynamically on first use.
|
||||
//
|
||||
// Uses ctx.ui primitives from the host app for toast notifications,
|
||||
// side-panel preview, theme detection, and mobile awareness.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
@@ -11,14 +15,15 @@ Extensions.register({
|
||||
|
||||
_mermaidReady: false,
|
||||
_mermaidLoading: null,
|
||||
_ctx: null,
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
this._ctx = ctx;
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
// ── Block renderer: match ```mermaid, output placeholder ──
|
||||
// ── Block renderer: match ```mermaid ──
|
||||
ctx.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
@@ -28,15 +33,15 @@ Extensions.register({
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-toolbar">
|
||||
<span class="mermaid-title">📊 Diagram</span>
|
||||
<span class="mermaid-title">\u{1f4ca} Diagram</span>
|
||||
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
|
||||
<div class="mermaid-toolbar-btns">
|
||||
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
|
||||
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">−</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">⊡</button>
|
||||
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
|
||||
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="fullscreen" data-target="${id}" title="Toggle fullscreen">⛶</button>
|
||||
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Expand">\u26f6</button>
|
||||
<span class="mmd-sep"></span>
|
||||
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
|
||||
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
|
||||
@@ -45,13 +50,13 @@ Extensions.register({
|
||||
<div class="mermaid-viewport" data-viewport="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
|
||||
<div class="mermaid-loading">
|
||||
<span class="mermaid-spinner"></span> Rendering diagram…
|
||||
<span class="mermaid-spinner"></span> Rendering diagram\u2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>
|
||||
<span>📋 View source</span>
|
||||
<span>\u{1f4cb} View source</span>
|
||||
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
|
||||
</summary>
|
||||
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
|
||||
@@ -75,49 +80,38 @@ Extensions.register({
|
||||
self._renderDiagram(el);
|
||||
});
|
||||
|
||||
// Wire toolbar buttons (event delegation on the container)
|
||||
self._wireToolbar(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load mermaid library
|
||||
this._loadMermaid();
|
||||
},
|
||||
|
||||
// ── ViewBox Zoom/Pan State ──────────────
|
||||
|
||||
// State stores the current viewBox and the original (natural) viewBox
|
||||
// for computing zoom level relative to the full diagram.
|
||||
_getState(id) {
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return null;
|
||||
if (!vp._mmdState) {
|
||||
vp._mmdState = {
|
||||
// Natural (full) viewBox from the SVG — set after render
|
||||
natX: 0, natY: 0, natW: 0, natH: 0,
|
||||
// Current viewBox
|
||||
vbX: 0, vbY: 0, vbW: 0, vbH: 0,
|
||||
// Drag state
|
||||
dragging: false, startX: 0, startY: 0,
|
||||
startVbX: 0, startVbY: 0,
|
||||
// Track initialization
|
||||
ready: false,
|
||||
};
|
||||
}
|
||||
return vp._mmdState;
|
||||
},
|
||||
|
||||
// Call after SVG is rendered to capture the natural viewBox
|
||||
_initState(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
// Parse the SVG's viewBox
|
||||
const vb = svg.getAttribute('viewBox');
|
||||
if (!vb) {
|
||||
// No viewBox — create one from the SVG's rendered size
|
||||
const bbox = svg.getBBox();
|
||||
state.natX = bbox.x;
|
||||
state.natY = bbox.y;
|
||||
@@ -131,7 +125,6 @@ Extensions.register({
|
||||
state.natH = parts[3] || 0;
|
||||
}
|
||||
|
||||
// Start with the full diagram visible
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
@@ -149,29 +142,23 @@ Extensions.register({
|
||||
|
||||
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
|
||||
|
||||
// Update zoom label — zoom = natural width / current width
|
||||
const zoomPct = Math.round((state.natW / state.vbW) * 100);
|
||||
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
||||
if (label) label.textContent = zoomPct + '%';
|
||||
},
|
||||
|
||||
// Zoom toward/away from center of current view
|
||||
_zoom(id, factor) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
|
||||
// factor > 0 = zoom in (shrink viewBox), factor < 0 = zoom out
|
||||
const scale = 1 / (1 + factor);
|
||||
|
||||
const newW = state.vbW * scale;
|
||||
const newH = state.vbH * scale;
|
||||
|
||||
// Clamp: don't zoom out beyond 0.5x natural, don't zoom in beyond 20x
|
||||
const minW = state.natW / 20;
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
// Keep the center point stable
|
||||
const cx = state.vbX + state.vbW / 2;
|
||||
const cy = state.vbY + state.vbH / 2;
|
||||
|
||||
@@ -183,7 +170,6 @@ Extensions.register({
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
// Zoom centered on a specific viewport pixel coordinate
|
||||
_zoomAt(id, factor, clientX, clientY) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
@@ -193,7 +179,6 @@ Extensions.register({
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0 || svgRect.height === 0) return;
|
||||
|
||||
// Map client coords to viewBox coords (the point we want to keep stable)
|
||||
const fx = (clientX - svgRect.left) / svgRect.width;
|
||||
const fy = (clientY - svgRect.top) / svgRect.height;
|
||||
const pointX = state.vbX + fx * state.vbW;
|
||||
@@ -207,7 +192,6 @@ Extensions.register({
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
// Keep the mouse point at the same fractional position
|
||||
state.vbW = newW;
|
||||
state.vbH = newH;
|
||||
state.vbX = pointX - fx * newW;
|
||||
@@ -227,8 +211,6 @@ Extensions.register({
|
||||
},
|
||||
|
||||
_zoomFit(id) {
|
||||
// Fit = show the full diagram, same as reset
|
||||
// but adjust aspect ratio to match viewport
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
@@ -240,16 +222,13 @@ Extensions.register({
|
||||
const vpAspect = vpRect.width / vpRect.height;
|
||||
const natAspect = state.natW / state.natH;
|
||||
|
||||
// Reset to natural, then expand the smaller dimension to fill viewport
|
||||
if (vpAspect > natAspect) {
|
||||
// Viewport is wider — expand width to match
|
||||
const newW = state.natH * vpAspect;
|
||||
state.vbX = state.natX - (newW - state.natW) / 2;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = newW;
|
||||
state.vbH = state.natH;
|
||||
} else {
|
||||
// Viewport is taller — expand height to match
|
||||
const newH = state.natW / vpAspect;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY - (newH - state.natH) / 2;
|
||||
@@ -260,6 +239,18 @@ Extensions.register({
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
// ── Expand (context-aware) ──────────────
|
||||
// Side panel open → pop out into it (replaces current content).
|
||||
// Side panel closed → fullscreen overlay.
|
||||
|
||||
_expand(id) {
|
||||
if (this._ctx.ui.isPanelOpen()) {
|
||||
this._popOutToPanel(id);
|
||||
} else {
|
||||
this._toggleFullscreen(id);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Fullscreen ──────────────────────────
|
||||
|
||||
_toggleFullscreen(id) {
|
||||
@@ -267,25 +258,31 @@ Extensions.register({
|
||||
if (!block) return;
|
||||
|
||||
const isFS = block.classList.toggle('mermaid-fullscreen');
|
||||
const btn = block.querySelector('[data-action="fullscreen"]');
|
||||
|
||||
if (isFS) {
|
||||
// Entering fullscreen
|
||||
if (btn) btn.textContent = '✕';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Escape key listener
|
||||
// Close button overlay (always visible — critical for mobile)
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'mmd-fullscreen-close';
|
||||
closeBtn.innerHTML = '\u2715';
|
||||
closeBtn.title = 'Close fullscreen';
|
||||
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
|
||||
block.appendChild(closeBtn);
|
||||
|
||||
// Escape listener (desktop convenience, not sole exit)
|
||||
block._mmdEscHandler = (e) => {
|
||||
if (e.key === 'Escape') this._toggleFullscreen(id);
|
||||
};
|
||||
document.addEventListener('keydown', block._mmdEscHandler);
|
||||
|
||||
// Re-fit after layout change
|
||||
requestAnimationFrame(() => this._zoomFit(id));
|
||||
} else {
|
||||
// Exiting fullscreen
|
||||
if (btn) btn.textContent = '⛶';
|
||||
document.body.style.overflow = '';
|
||||
|
||||
const closeBtn = block.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
|
||||
if (block._mmdEscHandler) {
|
||||
document.removeEventListener('keydown', block._mmdEscHandler);
|
||||
delete block._mmdEscHandler;
|
||||
@@ -293,16 +290,47 @@ Extensions.register({
|
||||
}
|
||||
},
|
||||
|
||||
// ── Side Panel Pop-out ──────────────────
|
||||
|
||||
_popOutToPanel(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox',
|
||||
`${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
}
|
||||
clone.setAttribute('width', '100%');
|
||||
clone.removeAttribute('height');
|
||||
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
const isDark = this._ctx.ui.isDark();
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||||
align-items: flex-start; min-height: 100vh;
|
||||
background: ${isDark ? '#1a1a2e' : '#fff'};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head><body>${clone.outerHTML}</body></html>`;
|
||||
|
||||
this._ctx.ui.openPreview(html);
|
||||
},
|
||||
|
||||
// ── Toolbar Wiring ──────────────────────
|
||||
|
||||
_wireToolbar(container) {
|
||||
const self = this;
|
||||
|
||||
// Skip if already wired
|
||||
if (container._mmdWired) return;
|
||||
container._mmdWired = true;
|
||||
|
||||
// Button clicks (delegation)
|
||||
container.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
@@ -315,10 +343,10 @@ Extensions.register({
|
||||
case 'zoom-out': self._zoom(id, -0.2); break;
|
||||
case 'zoom-reset': self._zoomReset(id); break;
|
||||
case 'zoom-fit': self._zoomFit(id); break;
|
||||
case 'fullscreen': self._toggleFullscreen(id); break;
|
||||
case 'expand': self._expand(id); break;
|
||||
case 'export-svg': self._exportSVG(id); break;
|
||||
case 'export-png': self._exportPNG(id); break;
|
||||
case 'copy-src': self._copySource(id, btn); break;
|
||||
case 'copy-src': self._copySource(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -328,14 +356,12 @@ Extensions.register({
|
||||
vp._mmdWired = true;
|
||||
const id = vp.dataset.viewport;
|
||||
|
||||
// Mouse wheel zoom — centered on cursor position
|
||||
vp.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
self._zoomAt(id, delta, e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
// Pan: mousedown → mousemove → mouseup
|
||||
vp.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const state = self._getState(id);
|
||||
@@ -358,7 +384,6 @@ Extensions.register({
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0) return;
|
||||
|
||||
// Convert pixel delta to viewBox delta
|
||||
const pxToVb = state.vbW / svgRect.width;
|
||||
const dx = (e.clientX - state.startX) * pxToVb;
|
||||
const dy = (e.clientY - state.startY) * pxToVb;
|
||||
@@ -377,7 +402,6 @@ Extensions.register({
|
||||
vp.addEventListener('mouseup', endDrag);
|
||||
vp.addEventListener('mouseleave', endDrag);
|
||||
|
||||
// Touch: single-finger pan, two-finger pinch zoom
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = null;
|
||||
vp.addEventListener('touchstart', (e) => {
|
||||
@@ -452,7 +476,6 @@ Extensions.register({
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
// Export with the natural viewBox (full diagram)
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
@@ -471,7 +494,6 @@ Extensions.register({
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
// Export full diagram at natural size
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
clone.setAttribute('width', state.natW);
|
||||
@@ -485,7 +507,7 @@ Extensions.register({
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const scale = 2; // 2x for retina
|
||||
const scale = 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth * scale;
|
||||
canvas.height = img.naturalHeight * scale;
|
||||
@@ -515,13 +537,11 @@ Extensions.register({
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
},
|
||||
|
||||
_copySource(id, btn) {
|
||||
_copySource(id) {
|
||||
const code = document.querySelector(`[data-source="${id}"]`);
|
||||
if (!code) return;
|
||||
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => { btn.textContent = orig; }, 1500);
|
||||
this._ctx.ui.toast('Source copied', 'success');
|
||||
});
|
||||
},
|
||||
|
||||
@@ -544,11 +564,9 @@ Extensions.register({
|
||||
svgEl.removeAttribute('height');
|
||||
svgEl.setAttribute('width', '100%');
|
||||
svgEl.style.maxWidth = 'none';
|
||||
// preserveAspectRatio ensures the viewBox maps cleanly
|
||||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
|
||||
// Initialize viewBox-based zoom state
|
||||
this._initState(id);
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
@@ -609,12 +627,9 @@ Extensions.register({
|
||||
_initMermaid() {
|
||||
if (typeof mermaid === 'undefined') return;
|
||||
|
||||
const isDark = document.body.classList.contains('dark-theme') ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'inherit',
|
||||
logLevel: 'error',
|
||||
@@ -642,8 +657,6 @@ Extensions.register({
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
background: var(--bg-2, #1a1a2e);
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
flex: 1; max-height: none;
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
|
||||
@@ -651,6 +664,22 @@ Extensions.register({
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* Fullscreen close button — always visible, critical for mobile */
|
||||
.mmd-fullscreen-close {
|
||||
position: absolute; top: 12px; right: 12px; z-index: 10001;
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
background: var(--bg-raised, rgba(0,0,0,0.6));
|
||||
border: 1px solid var(--border, rgba(255,255,255,0.2));
|
||||
color: var(--text, #fff); font-size: 20px; line-height: 1;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
.mmd-fullscreen-close:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.15));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.mermaid-toolbar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
@@ -671,7 +700,7 @@ Extensions.register({
|
||||
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
|
||||
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
/* Viewport: contains the SVG, no overflow hidden needed — viewBox handles clipping */
|
||||
/* Viewport */
|
||||
.mermaid-viewport {
|
||||
overflow: hidden; position: relative;
|
||||
min-height: 80px; max-height: 600px;
|
||||
@@ -718,8 +747,16 @@ Extensions.register({
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
|
||||
/* Hide source panel in fullscreen — it's in the way */
|
||||
/* Hide source panel in fullscreen */
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
|
||||
|
||||
/* ── Mobile adjustments ── */
|
||||
@media (max-width: 768px) {
|
||||
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
|
||||
.mmd-btn { padding: 4px 10px; font-size: 12px; }
|
||||
.mmd-sep { margin: 0 2px; }
|
||||
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
@@ -732,9 +769,10 @@ Extensions.register({
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-mermaid-renderer')?.remove();
|
||||
// Clean up any fullscreen state
|
||||
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
|
||||
el.classList.remove('mermaid-fullscreen');
|
||||
const closeBtn = el.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
@@ -634,7 +634,19 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.ext-rendered {
|
||||
margin: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
.ext-popout-btn {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
color: var(--text-3); cursor: pointer; font-size: 14px;
|
||||
width: 26px; height: 26px; border-radius: var(--radius);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity 0.15s, background 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
.ext-rendered:hover .ext-popout-btn { opacity: 1; }
|
||||
.ext-popout-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* HTML preview */
|
||||
/* ── Side Panel (Preview + Notes) ──────── */
|
||||
@@ -674,20 +686,9 @@ a:hover { text-decoration: underline; }
|
||||
transition: all var(--transition); display: flex; align-items: center;
|
||||
}
|
||||
.side-panel-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.side-panel-tabs {
|
||||
display: flex; gap: 2px; background: var(--bg-surface);
|
||||
border-radius: var(--radius); padding: 2px;
|
||||
}
|
||||
.side-panel-tab {
|
||||
padding: 4px 14px; border-radius: calc(var(--radius) - 2px);
|
||||
font-size: 12px; font-family: var(--font); font-weight: 500;
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; transition: all var(--transition);
|
||||
}
|
||||
.side-panel-tab:hover { color: var(--text); }
|
||||
.side-panel-tab.active {
|
||||
background: var(--bg-raised); color: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
.side-panel-label {
|
||||
font-size: 12px; font-family: var(--font); font-weight: 600;
|
||||
color: var(--text-2); white-space: nowrap;
|
||||
}
|
||||
.side-panel-close {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
@@ -728,12 +729,58 @@ a:hover { text-decoration: underline; }
|
||||
flex: 1; overflow-y: auto; min-height: 0;
|
||||
}
|
||||
|
||||
/* Side panel overlay (mobile tap-to-close, mirrors sidebar-overlay) */
|
||||
.side-panel-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 199;
|
||||
background: var(--overlay);
|
||||
}
|
||||
|
||||
/* Mobile: full-width overlay */
|
||||
@media (max-width: 768px) {
|
||||
.side-panel.open {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: 100vw; min-width: 100vw; z-index: 200;
|
||||
}
|
||||
/* Disable dual-view on mobile — not enough space */
|
||||
.side-panel-body.dual {
|
||||
display: flex; flex-direction: column;
|
||||
grid-template-columns: unset !important;
|
||||
}
|
||||
.side-panel-divider { display: none !important; }
|
||||
.side-panel-dual-btn { display: none; }
|
||||
|
||||
/* Larger touch targets on mobile */
|
||||
.side-panel-close { font-size: 20px; padding: 4px 10px; }
|
||||
.side-panel-action-btn { padding: 6px 8px; }
|
||||
.side-panel-header { padding: 10px 12px; }
|
||||
}
|
||||
|
||||
/* ── Dual-View Mode ───────────────────────── */
|
||||
.side-panel.dual-open {
|
||||
width: 680px; min-width: 680px;
|
||||
}
|
||||
.side-panel-body.dual {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 6px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.side-panel-body.dual > .side-panel-page {
|
||||
overflow-y: auto; min-width: 0; min-height: 0;
|
||||
}
|
||||
.side-panel-divider {
|
||||
grid-column: 2;
|
||||
width: 6px; cursor: col-resize;
|
||||
background: var(--border);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.side-panel-divider:hover,
|
||||
.side-panel-divider:active {
|
||||
background: var(--accent); opacity: 0.5;
|
||||
}
|
||||
|
||||
.side-panel-dual-btn.active {
|
||||
background: var(--accent); color: var(--bg);
|
||||
}
|
||||
|
||||
.msg-text ul, .msg-text ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||
|
||||
@@ -191,33 +191,33 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Side Panel (preview + notes) -->
|
||||
<!-- Side Panel (registry-driven, see panels.js) -->
|
||||
<aside class="side-panel" id="sidePanel">
|
||||
<div class="side-panel-resize" id="sidePanelResize"></div>
|
||||
<div class="side-panel-header">
|
||||
<div class="side-panel-tabs" id="sidePanelTabs">
|
||||
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
|
||||
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
|
||||
</div>
|
||||
<span class="side-panel-label" id="sidePanelLabel"></span>
|
||||
<div class="side-panel-actions">
|
||||
<button class="side-panel-action-btn" id="sidePanelClearBtn" onclick="clearPreview()" title="Clear preview" style="display:none">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
<span id="sidePanelPanelActions"></span>
|
||||
<button class="side-panel-action-btn side-panel-dual-btn" id="sidePanelDualBtn" onclick="PanelRegistry.toggleDual()" title="Toggle split view" style="display:none">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>
|
||||
</button>
|
||||
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
</button>
|
||||
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel">✕</button>
|
||||
<button class="side-panel-close" onclick="PanelRegistry.closeAll()" title="Close panel">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-panel-body">
|
||||
<!-- Preview tab -->
|
||||
<div class="side-panel-page" id="sidePanelPreview">
|
||||
<div class="side-panel-body" id="sidePanelBody">
|
||||
<!-- Dual-view divider (hidden by default, shown in dual mode) -->
|
||||
<div class="side-panel-divider" id="sidePanelDivider" style="display:none"></div>
|
||||
<!-- Preview panel -->
|
||||
<div class="side-panel-page" id="sidePanelPreview" style="display:none">
|
||||
<div class="side-panel-empty" id="previewEmpty">
|
||||
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
|
||||
</div>
|
||||
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
|
||||
</div>
|
||||
<!-- Notes tab -->
|
||||
<!-- Notes panel -->
|
||||
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
|
||||
<div class="notes-toolbar">
|
||||
<div class="notes-search-wrap">
|
||||
@@ -313,6 +313,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="side-panel-overlay" id="sidePanelOverlay"></div>
|
||||
|
||||
</div><!-- .app-body -->
|
||||
|
||||
@@ -1111,6 +1112,7 @@
|
||||
<script src="js/events.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/api.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/panels.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
|
||||
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
|
||||
|
||||
@@ -422,8 +422,14 @@ function initListeners() {
|
||||
_initAdminListeners(); // from admin-handlers.js
|
||||
_initNotesListeners(); // from notes.js
|
||||
_initAttachmentListeners(); // from attachments.js
|
||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
|
||||
_initSidePanelResize(); // from ui-format.js
|
||||
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
|
||||
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
|
||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
||||
_initSidePanelResize(); // from panels.js
|
||||
_initDualDivider(); // from panels.js — dual-view split drag
|
||||
_initPanelSwipe(); // from panels.js — mobile swipe navigation
|
||||
_initPanelResponsive(); // from panels.js — auto-collapse dual on resize
|
||||
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
|
||||
}
|
||||
|
||||
function _initGlobalKeyboard() {
|
||||
@@ -438,8 +444,8 @@ function _initGlobalKeyboard() {
|
||||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||||
closeCmdPalette(); return;
|
||||
}
|
||||
if (document.getElementById('sidePanel')?.classList.contains('open')) {
|
||||
closeSidePanel(); return;
|
||||
if (PanelRegistry.isContainerOpen()) {
|
||||
PanelRegistry.closeAll(); return;
|
||||
}
|
||||
const open = [...document.querySelectorAll('.modal-overlay.active')];
|
||||
if (open.length) closeModal(open[open.length - 1].id);
|
||||
@@ -453,6 +459,20 @@ function _initGlobalKeyboard() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+\: cycle side panels
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PanelRegistry.cycle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Shift+\: toggle dual-view split
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
PanelRegistry.toggleDual();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Shift+S: focus sidebar search
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -196,12 +196,59 @@ const Extensions = {
|
||||
},
|
||||
},
|
||||
|
||||
// UI injection points (v0.17.0 — stub for now)
|
||||
// UI primitives — safe wrappers around primary UI components.
|
||||
// Extensions use these instead of reaching into globals directly.
|
||||
ui: {
|
||||
inject: (region, el) => {
|
||||
/** Show a toast notification. type: 'success' | 'error' | 'warning' | 'info' */
|
||||
toast(msg, type = 'success') {
|
||||
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
|
||||
},
|
||||
|
||||
/** Open the side-panel preview with arbitrary HTML content. */
|
||||
openPreview(html) {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame) {
|
||||
frame.srcdoc = html;
|
||||
frame.style.display = '';
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
if (typeof PanelRegistry !== 'undefined') {
|
||||
PanelRegistry.open('preview');
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
},
|
||||
|
||||
/** Is the current theme dark? */
|
||||
isDark() {
|
||||
return document.body.classList.contains('dark-theme') ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
},
|
||||
|
||||
/** Is the viewport at mobile width (≤ 768px)? */
|
||||
isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
},
|
||||
|
||||
/** Is the side panel container currently open? */
|
||||
isPanelOpen() {
|
||||
if (typeof PanelRegistry !== 'undefined') return PanelRegistry.isContainerOpen();
|
||||
return false;
|
||||
},
|
||||
|
||||
/** Show a confirm dialog. Returns Promise<boolean>. */
|
||||
confirm(msg, opts) {
|
||||
if (typeof showConfirm === 'function') return showConfirm(msg, opts);
|
||||
return Promise.resolve(window.confirm(msg));
|
||||
},
|
||||
|
||||
/** Inject an element into a named UI region (stub for future use). */
|
||||
inject(region, el) {
|
||||
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
|
||||
},
|
||||
createMenu: (anchor, opts) => {
|
||||
|
||||
/** Create a popup menu anchored to an element. */
|
||||
createMenu(anchor, opts) {
|
||||
if (typeof createPopupMenu === 'function') return createPopupMenu(anchor, opts);
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -12,14 +12,12 @@ var _noteEditor = null; // CM6 noteEditor instance
|
||||
// ── Notes ─────────────────────────────────────
|
||||
|
||||
async function openNotes() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
const notesTab = document.getElementById('sidePanelNotes');
|
||||
// If notes panel is already open and showing, close it
|
||||
if (panel?.classList.contains('open') && notesTab?.style.display !== 'none') {
|
||||
closeSidePanel();
|
||||
// Toggle: if notes is active, close it; otherwise open it
|
||||
if (PanelRegistry.isOpen('notes')) {
|
||||
PanelRegistry.close('notes');
|
||||
return;
|
||||
}
|
||||
openSidePanel('notes');
|
||||
PanelRegistry.open('notes');
|
||||
_exitSelectMode();
|
||||
await loadNotesList();
|
||||
await loadNoteFolders();
|
||||
@@ -745,6 +743,38 @@ function _showSaveToNoteModal(opts) {
|
||||
|
||||
// ── Notes Listeners (extracted from initListeners) ──
|
||||
|
||||
function _registerNotesPanel() {
|
||||
const el = document.getElementById('sidePanelNotes');
|
||||
if (!el) return;
|
||||
|
||||
PanelRegistry.register('notes', {
|
||||
element: el,
|
||||
label: 'Notes',
|
||||
onOpen() {
|
||||
// Loading is handled by openNotes() which calls loadNotesList
|
||||
},
|
||||
saveState() {
|
||||
const listView = document.getElementById('notesListView');
|
||||
const editorView = document.getElementById('notesEditorView');
|
||||
const graphView = document.getElementById('notesGraphView');
|
||||
let viewMode = 'list';
|
||||
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
|
||||
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
|
||||
return {
|
||||
scrollTop: listView?.scrollTop || 0,
|
||||
editingNoteId: _editingNoteId,
|
||||
viewMode,
|
||||
};
|
||||
},
|
||||
restoreState(state) {
|
||||
if (state.scrollTop) {
|
||||
const listView = document.getElementById('notesListView');
|
||||
if (listView) listView.scrollTop = state.scrollTop;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function _initNotesListeners() {
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
||||
|
||||
646
src/js/panels.js
Normal file
646
src/js/panels.js
Normal file
@@ -0,0 +1,646 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Panel Registry
|
||||
// ==========================================
|
||||
// Independent panel system replacing the hardcoded two-tab side panel.
|
||||
// Each panel registers with name, DOM element, and lifecycle hooks.
|
||||
// The registry owns all open/close/focus logic — individual panels
|
||||
// never touch the <aside> container directly.
|
||||
//
|
||||
// Supports single-panel and dual-view modes. In dual mode the panel
|
||||
// body splits into primary (left) + divider + secondary (right) via
|
||||
// CSS grid. The split ratio is drag-adjustable.
|
||||
//
|
||||
// See DESIGN-0.18.1.md for full spec.
|
||||
|
||||
// ── Panel Registry ──────────────────────────
|
||||
|
||||
const PanelRegistry = {
|
||||
_panels: {}, // { name: { element, label, onOpen, onClose, saveState, restoreState, actions, state } }
|
||||
_active: null, // primary (left) panel name
|
||||
_secondary: null, // secondary (right) panel name — null when not in dual mode
|
||||
_dualMode: false,
|
||||
_splitRatio: 0.5, // 0–1, fraction of space allocated to primary
|
||||
_order: [], // registration order (for tab rendering + cycle)
|
||||
|
||||
/**
|
||||
* Register a panel.
|
||||
* @param {string} name — unique identifier (e.g. 'preview', 'notes')
|
||||
* @param {object} opts
|
||||
* @param {HTMLElement} opts.element — the .side-panel-page div
|
||||
* @param {string} opts.label — display name for tab button
|
||||
* @param {Function} [opts.onOpen] — called when panel becomes visible
|
||||
* @param {Function} [opts.onClose] — called when panel is hidden
|
||||
* @param {Function} [opts.saveState] — returns state snapshot before hiding
|
||||
* @param {Function} [opts.restoreState]— receives state snapshot on re-show
|
||||
* @param {Array} [opts.actions] — per-panel action button configs
|
||||
* Each action: { id, icon, title, onClick, visible? }
|
||||
*/
|
||||
register(name, opts) {
|
||||
if (this._panels[name]) {
|
||||
console.warn(`[panels] panel "${name}" already registered, replacing`);
|
||||
}
|
||||
this._panels[name] = {
|
||||
element: opts.element,
|
||||
label: opts.label || name,
|
||||
onOpen: opts.onOpen || null,
|
||||
onClose: opts.onClose || null,
|
||||
saveState: opts.saveState || null,
|
||||
restoreState: opts.restoreState || null,
|
||||
actions: opts.actions || [],
|
||||
state: {},
|
||||
};
|
||||
if (!this._order.includes(name)) {
|
||||
this._order.push(name);
|
||||
}
|
||||
this._renderLabel();
|
||||
},
|
||||
|
||||
/**
|
||||
* Open a panel (and the container if closed).
|
||||
*
|
||||
* Single mode: hides current active, shows requested panel.
|
||||
* Dual mode:
|
||||
* - Already visible (active or secondary): swap it to primary.
|
||||
* - Not visible: replaces secondary panel.
|
||||
*/
|
||||
open(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) {
|
||||
console.warn(`[panels] unknown panel: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this._container();
|
||||
if (!container) return;
|
||||
|
||||
if (this._dualMode) {
|
||||
// Already primary — no-op
|
||||
if (this._active === name) {
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
// Currently secondary — promote to primary, swap
|
||||
if (this._secondary === name) {
|
||||
const oldActive = this._active;
|
||||
this._active = name;
|
||||
this._secondary = oldActive;
|
||||
this._applyDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
// New panel — replace secondary
|
||||
if (this._secondary) this._hide(this._secondary);
|
||||
this._show(name);
|
||||
this._secondary = name;
|
||||
this._applyDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Single mode ──
|
||||
if (this._active && this._active !== name) {
|
||||
this._hide(this._active);
|
||||
}
|
||||
|
||||
container.classList.add('open');
|
||||
this._show(name);
|
||||
this._active = name;
|
||||
this._showOverlay();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Close a specific panel.
|
||||
*
|
||||
* Dual mode: closing either panel exits dual, keeping the other.
|
||||
* Single mode: closes container.
|
||||
*/
|
||||
close(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) return;
|
||||
|
||||
if (this._dualMode) {
|
||||
if (name === this._secondary) {
|
||||
this._hide(this._secondary);
|
||||
this._secondary = null;
|
||||
this._exitDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
if (name === this._active) {
|
||||
this._hide(this._active);
|
||||
// Promote secondary to primary
|
||||
this._active = this._secondary;
|
||||
this._secondary = null;
|
||||
this._exitDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
return; // not visible — nothing to close
|
||||
}
|
||||
|
||||
// ── Single mode ──
|
||||
if (this._active === name) {
|
||||
this._hide(name);
|
||||
this._active = null;
|
||||
this._closeContainer();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle: if this panel is visible, close it. Otherwise, open it.
|
||||
*/
|
||||
toggle(name) {
|
||||
if (this.isOpen(name)) {
|
||||
this.close(name);
|
||||
} else {
|
||||
this.open(name);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close all panels and the container.
|
||||
*/
|
||||
closeAll() {
|
||||
if (this._secondary) {
|
||||
this._hide(this._secondary);
|
||||
this._secondary = null;
|
||||
}
|
||||
if (this._active) {
|
||||
this._hide(this._active);
|
||||
this._active = null;
|
||||
}
|
||||
if (this._dualMode) this._exitDualLayout();
|
||||
this._closeContainer();
|
||||
},
|
||||
|
||||
/** Is a specific panel currently visible? */
|
||||
isOpen(name) {
|
||||
return this._active === name || this._secondary === name;
|
||||
},
|
||||
|
||||
/** Is the panel container open at all? */
|
||||
isContainerOpen() {
|
||||
return this._container()?.classList.contains('open') || false;
|
||||
},
|
||||
|
||||
/** Get the primary panel name (or null). */
|
||||
active() {
|
||||
return this._active;
|
||||
},
|
||||
|
||||
/** Is dual-view mode active? */
|
||||
isDual() {
|
||||
return this._dualMode;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle dual-view mode.
|
||||
*
|
||||
* Entering: picks the next registered panel as secondary. If
|
||||
* container is closed, opens first two panels. Needs ≥ 2 panels.
|
||||
* Exiting: hides secondary, keeps primary.
|
||||
*/
|
||||
toggleDual() {
|
||||
if (this._dualMode) {
|
||||
// Exit dual
|
||||
if (this._secondary) this._hide(this._secondary);
|
||||
this._secondary = null;
|
||||
this._exitDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter dual — need at least 2 panels
|
||||
if (this._order.length < 2) return;
|
||||
|
||||
const container = this._container();
|
||||
if (!container) return;
|
||||
|
||||
// If container not open, open first panel as primary
|
||||
if (!this._active) {
|
||||
container.classList.add('open');
|
||||
this._show(this._order[0]);
|
||||
this._active = this._order[0];
|
||||
}
|
||||
|
||||
// Pick secondary: first registered panel that isn't active
|
||||
const secondary = this._order.find(n => n !== this._active);
|
||||
if (!secondary) return;
|
||||
|
||||
this._show(secondary);
|
||||
this._secondary = secondary;
|
||||
this._dualMode = true;
|
||||
this._applyDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cycle to the next registered panel. If container is closed, open
|
||||
* the first panel. Wraps around.
|
||||
*
|
||||
* In dual mode, cycles the secondary panel through non-primary panels.
|
||||
*/
|
||||
cycle() {
|
||||
if (this._order.length === 0) return;
|
||||
|
||||
if (!this._active) {
|
||||
this.open(this._order[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._dualMode && this._secondary) {
|
||||
// Cycle secondary through panels that aren't the primary
|
||||
const others = this._order.filter(n => n !== this._active);
|
||||
if (others.length < 2) return; // only one option, already showing
|
||||
const idx = others.indexOf(this._secondary);
|
||||
const next = others[(idx + 1) % others.length];
|
||||
if (next === this._secondary) return;
|
||||
this._hide(this._secondary);
|
||||
this._show(next);
|
||||
this._secondary = next;
|
||||
this._applyDualLayout();
|
||||
this._renderLabel();
|
||||
this._renderActions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single mode: cycle active
|
||||
const idx = this._order.indexOf(this._active);
|
||||
const next = this._order[(idx + 1) % this._order.length];
|
||||
this.open(next);
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────
|
||||
|
||||
_container() {
|
||||
return document.getElementById('sidePanel');
|
||||
},
|
||||
|
||||
_body() {
|
||||
return document.getElementById('sidePanelBody');
|
||||
},
|
||||
|
||||
/** Show a panel: set display, restore state, fire onOpen. */
|
||||
_show(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) return;
|
||||
|
||||
panel.element.style.display = '';
|
||||
|
||||
// Restore state if previously saved
|
||||
if (panel.restoreState && Object.keys(panel.state).length > 0) {
|
||||
panel.restoreState(panel.state);
|
||||
}
|
||||
|
||||
if (panel.onOpen) panel.onOpen();
|
||||
},
|
||||
|
||||
/** Hide a panel: save state, call onClose, set display:none, clear grid placement. */
|
||||
_hide(name) {
|
||||
const panel = this._panels[name];
|
||||
if (!panel) return;
|
||||
|
||||
if (panel.saveState) {
|
||||
panel.state = panel.saveState() || {};
|
||||
}
|
||||
|
||||
if (panel.onClose) panel.onClose();
|
||||
|
||||
panel.element.style.display = 'none';
|
||||
panel.element.style.gridColumn = '';
|
||||
},
|
||||
|
||||
/** Close the <aside> container. */
|
||||
_closeContainer() {
|
||||
const container = this._container();
|
||||
if (!container) return;
|
||||
container.classList.remove('open', 'fullscreen', 'dual-open');
|
||||
container.style.width = '';
|
||||
container.style.minWidth = '';
|
||||
this._hideOverlay();
|
||||
},
|
||||
|
||||
// ── Dual layout ─────────────────────────
|
||||
|
||||
/** Apply CSS grid layout for dual-view. */
|
||||
_applyDualLayout() {
|
||||
this._dualMode = true;
|
||||
const body = this._body();
|
||||
const container = this._container();
|
||||
const divider = document.getElementById('sidePanelDivider');
|
||||
if (!body || !container) return;
|
||||
|
||||
body.classList.add('dual');
|
||||
container.classList.add('dual-open');
|
||||
if (divider) divider.style.display = '';
|
||||
|
||||
// Assign grid columns: primary=1, divider=2, secondary=3
|
||||
const primary = this._panels[this._active];
|
||||
const secondary = this._panels[this._secondary];
|
||||
if (primary) primary.element.style.gridColumn = '1';
|
||||
if (secondary) secondary.element.style.gridColumn = '3';
|
||||
|
||||
this._applySplitRatio();
|
||||
},
|
||||
|
||||
/** Remove dual layout, return to single-panel flexbox. */
|
||||
_exitDualLayout() {
|
||||
this._dualMode = false;
|
||||
const body = this._body();
|
||||
const container = this._container();
|
||||
const divider = document.getElementById('sidePanelDivider');
|
||||
if (body) {
|
||||
body.classList.remove('dual');
|
||||
body.style.gridTemplateColumns = '';
|
||||
}
|
||||
if (container) container.classList.remove('dual-open');
|
||||
if (divider) divider.style.display = 'none';
|
||||
|
||||
// Clear grid-column on all panels
|
||||
for (const name of this._order) {
|
||||
const p = this._panels[name];
|
||||
if (p) p.element.style.gridColumn = '';
|
||||
}
|
||||
},
|
||||
|
||||
/** Set grid-template-columns based on current _splitRatio. */
|
||||
_applySplitRatio() {
|
||||
const body = this._body();
|
||||
if (!body || !this._dualMode) return;
|
||||
const r = this._splitRatio;
|
||||
body.style.gridTemplateColumns = `${r}fr 6px ${1 - r}fr`;
|
||||
},
|
||||
|
||||
// ── Mobile overlay ──────────────────────
|
||||
|
||||
_isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
},
|
||||
|
||||
/** Show overlay behind panel on mobile (tap-to-close). */
|
||||
_showOverlay() {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (ov && this._isMobile()) ov.style.display = 'block';
|
||||
},
|
||||
|
||||
/** Hide the mobile overlay. */
|
||||
_hideOverlay() {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (ov) ov.style.display = 'none';
|
||||
},
|
||||
|
||||
// ── Tab and action rendering ────────────
|
||||
|
||||
/** Update the header label to show the active panel name. */
|
||||
_renderLabel() {
|
||||
const el = document.getElementById('sidePanelLabel');
|
||||
if (!el) return;
|
||||
if (this._active) {
|
||||
const p = this._panels[this._active];
|
||||
el.textContent = p ? p.label : '';
|
||||
} else {
|
||||
el.textContent = '';
|
||||
}
|
||||
|
||||
// Update dual-view toggle button visibility
|
||||
const dualBtn = document.getElementById('sidePanelDualBtn');
|
||||
if (dualBtn) {
|
||||
dualBtn.style.display = this._order.length >= 2 ? '' : 'none';
|
||||
dualBtn.classList.toggle('active', this._dualMode);
|
||||
}
|
||||
},
|
||||
|
||||
/** Render per-panel action buttons for the primary (active) panel. */
|
||||
_renderActions() {
|
||||
const slot = document.getElementById('sidePanelPanelActions');
|
||||
if (!slot) return;
|
||||
|
||||
if (!this._active) {
|
||||
slot.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = this._panels[this._active];
|
||||
slot.innerHTML = (panel.actions || [])
|
||||
.filter(a => !a.visible || a.visible())
|
||||
.map(a => `<button class="side-panel-action-btn" id="${a.id || ''}" onclick="${a.onClickName || ''}" title="${_escPanel(a.title || '')}">${a.icon || ''}</button>`)
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Refresh action button visibility (call after state changes). */
|
||||
refreshActions() {
|
||||
this._renderActions();
|
||||
},
|
||||
};
|
||||
|
||||
/** Minimal HTML escaping for panel labels/titles. */
|
||||
function _escPanel(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── Side Panel Container (global operations) ──
|
||||
|
||||
function toggleSidePanelFullscreen() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
if (!panel) return;
|
||||
panel.classList.toggle('fullscreen');
|
||||
const btn = document.getElementById('sidePanelFullscreenBtn');
|
||||
if (btn) {
|
||||
const isFS = panel.classList.contains('fullscreen');
|
||||
btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen';
|
||||
btn.innerHTML = isFS
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side Panel Resize (outer edge) ──────────
|
||||
|
||||
function _initSidePanelResize() {
|
||||
let startX, startW;
|
||||
const panel = document.getElementById('sidePanel');
|
||||
const handle = document.getElementById('sidePanelResize');
|
||||
if (!handle || !panel) return;
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (panel.classList.contains('fullscreen')) return;
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startW = panel.getBoundingClientRect().width;
|
||||
panel.style.transition = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (e) => {
|
||||
const delta = startX - e.clientX;
|
||||
const minW = PanelRegistry.isDual() ? 480 : 280;
|
||||
const newW = Math.max(minW, Math.min(window.innerWidth * 0.7, startW + delta));
|
||||
panel.style.width = newW + 'px';
|
||||
panel.style.minWidth = newW + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
panel.style.transition = '';
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dual-View Divider Resize ────────────────
|
||||
|
||||
function _initDualDivider() {
|
||||
const divider = document.getElementById('sidePanelDivider');
|
||||
if (!divider) return;
|
||||
|
||||
divider.addEventListener('mousedown', (e) => {
|
||||
if (!PanelRegistry.isDual()) return;
|
||||
e.preventDefault();
|
||||
|
||||
const body = document.getElementById('sidePanelBody');
|
||||
if (!body) return;
|
||||
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
body.style.cursor = 'col-resize';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (e) => {
|
||||
const x = e.clientX - bodyRect.left;
|
||||
const total = bodyRect.width - 6; // subtract divider width
|
||||
const ratio = Math.max(0.2, Math.min(0.8, x / (total + 6)));
|
||||
PanelRegistry._splitRatio = ratio;
|
||||
PanelRegistry._applySplitRatio();
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
body.style.cursor = '';
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mobile Swipe Navigation ─────────────────
|
||||
|
||||
function _initPanelSwipe() {
|
||||
const body = document.getElementById('sidePanelBody');
|
||||
if (!body) return;
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let tracking = false;
|
||||
|
||||
body.addEventListener('touchstart', (e) => {
|
||||
if (!PanelRegistry.isContainerOpen()) return;
|
||||
if (PanelRegistry._order.length < 2) return;
|
||||
// Only track single-finger swipes
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
tracking = true;
|
||||
}, { passive: true });
|
||||
|
||||
body.addEventListener('touchend', (e) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
|
||||
// Require horizontal movement > 80px and more horizontal than vertical
|
||||
if (Math.abs(dx) < 80 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
|
||||
if (dx > 0) {
|
||||
// Swipe right → previous panel
|
||||
_cyclePanelDirection(-1);
|
||||
} else {
|
||||
// Swipe left → next panel
|
||||
_cyclePanelDirection(1);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle panels in a specific direction.
|
||||
* direction: +1 = next, -1 = previous.
|
||||
*/
|
||||
function _cyclePanelDirection(direction) {
|
||||
const order = PanelRegistry._order;
|
||||
if (order.length < 2 || !PanelRegistry._active) return;
|
||||
|
||||
const idx = order.indexOf(PanelRegistry._active);
|
||||
const next = order[(idx + direction + order.length) % order.length];
|
||||
PanelRegistry.open(next);
|
||||
}
|
||||
|
||||
// ── Responsive Resize Handler ───────────────
|
||||
// Auto-exit dual mode when viewport shrinks below threshold.
|
||||
|
||||
function _initPanelResponsive() {
|
||||
let wasWide = window.innerWidth > 768;
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const isWide = window.innerWidth > 768;
|
||||
|
||||
// Viewport shrank to mobile while dual was active → collapse
|
||||
if (wasWide && !isWide && PanelRegistry.isDual()) {
|
||||
PanelRegistry.toggleDual(); // exits dual, keeps primary
|
||||
}
|
||||
|
||||
// Update overlay visibility: show on mobile if panel open, hide on desktop
|
||||
if (PanelRegistry.isContainerOpen()) {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (ov) {
|
||||
ov.style.display = isWide ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
wasWide = isWide;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Panel Overlay (mobile tap-to-close) ─────
|
||||
|
||||
function _initPanelOverlay() {
|
||||
const ov = document.getElementById('sidePanelOverlay');
|
||||
if (!ov) return;
|
||||
|
||||
ov.addEventListener('click', () => {
|
||||
PanelRegistry.closeAll();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Legacy Compat (thin wrappers, remove after full migration) ──
|
||||
|
||||
function openSidePanel(tab) {
|
||||
PanelRegistry.open(tab);
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
PanelRegistry.closeAll();
|
||||
}
|
||||
|
||||
function switchSidePanelTab(tab) {
|
||||
PanelRegistry.open(tab);
|
||||
}
|
||||
@@ -578,6 +578,11 @@ const UI = {
|
||||
const full = (reasoning ? '<think>' + reasoning + '</think>' : '') + content;
|
||||
document.getElementById('streamContent').innerHTML = formatMessage(full);
|
||||
this._scrollToBottom();
|
||||
|
||||
// Live-update preview panel if open (debounced)
|
||||
if (typeof _livePreviewUpdate === 'function') {
|
||||
_livePreviewUpdate(content);
|
||||
}
|
||||
}
|
||||
currentEvent = '';
|
||||
} catch (e) { /* partial JSON */ }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Chat Switchboard – UI Formatting & Helpers
|
||||
// ==========================================
|
||||
// Pure utility layer: esc(), markdown rendering, code blocks,
|
||||
// side panel, time formatting. Loaded before all other UI files.
|
||||
// preview panel, time formatting. Loaded after panels.js.
|
||||
|
||||
// ── HTML Escaping ───────────────────────────
|
||||
|
||||
@@ -102,7 +102,8 @@ function _formatMarked(text) {
|
||||
const decoded = _decodeHTML(code);
|
||||
const handled = Extensions.runBlockRenderers(lang, decoded, fakeContainer);
|
||||
if (handled && fakeContainer.innerHTML) {
|
||||
return `<div class="ext-rendered" data-ext-block="${codeId}">${fakeContainer.innerHTML}</div>`;
|
||||
const popBtn = `<button class="ext-popout-btn" onclick="popOutExtBlock('${codeId}')" title="Open in side panel">⧉</button>`;
|
||||
return `<div class="ext-rendered" data-ext-block="${codeId}" data-ext-lang="${lang}">${popBtn}${fakeContainer.innerHTML}</div>`;
|
||||
}
|
||||
if (handled && !fakeContainer.innerHTML) {
|
||||
console.warn(`[Extensions] Block renderer matched lang="${lang}" but produced empty output`);
|
||||
@@ -252,20 +253,16 @@ function toggleHTMLPreview(codeId) {
|
||||
frame.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
// Show clear button
|
||||
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = '';
|
||||
|
||||
openSidePanel('preview');
|
||||
PanelRegistry.open('preview');
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
const clearBtn = document.getElementById('sidePanelClearBtn');
|
||||
if (frame) { frame.srcdoc = ''; frame.style.display = 'none'; }
|
||||
if (empty) empty.style.display = '';
|
||||
if (clearBtn) clearBtn.style.display = 'none';
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
|
||||
function downloadCode(codeId, lang) {
|
||||
@@ -292,78 +289,144 @@ function downloadCode(codeId, lang) {
|
||||
UI.toast('Downloaded ' + filename, 'success');
|
||||
}
|
||||
|
||||
// ── Side Panel ──────────────────────────────
|
||||
// ── Preview Panel Registration ──────────────
|
||||
|
||||
function openSidePanel(tab) {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.add('open');
|
||||
if (tab) switchSidePanelTab(tab);
|
||||
}
|
||||
function _registerPreviewPanel() {
|
||||
const el = document.getElementById('sidePanelPreview');
|
||||
if (!el) return;
|
||||
|
||||
function closeSidePanel() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.remove('open', 'fullscreen');
|
||||
panel.style.width = ''; panel.style.minWidth = '';
|
||||
}
|
||||
|
||||
function switchSidePanelTab(tab) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.side-panel-tab').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
// Show/hide pages
|
||||
document.getElementById('sidePanelPreview').style.display = tab === 'preview' ? '' : 'none';
|
||||
document.getElementById('sidePanelNotes').style.display = tab === 'notes' ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleSidePanelFullscreen() {
|
||||
const panel = document.getElementById('sidePanel');
|
||||
panel.classList.toggle('fullscreen');
|
||||
const btn = document.getElementById('sidePanelFullscreenBtn');
|
||||
if (btn) {
|
||||
const isFS = panel.classList.contains('fullscreen');
|
||||
btn.title = isFS ? 'Exit fullscreen' : 'Toggle fullscreen';
|
||||
btn.innerHTML = isFS
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side Panel Resize ───────────────────────
|
||||
|
||||
function _initSidePanelResize() {
|
||||
let startX, startW;
|
||||
const panel = document.getElementById('sidePanel');
|
||||
const handle = document.getElementById('sidePanelResize');
|
||||
if (!handle || !panel) return;
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (panel.classList.contains('fullscreen')) return;
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startW = panel.getBoundingClientRect().width;
|
||||
panel.style.transition = 'none'; // disable animation during drag
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (e) => {
|
||||
const delta = startX - e.clientX; // dragging left = wider
|
||||
const newW = Math.max(280, Math.min(window.innerWidth * 0.7, startW + delta));
|
||||
panel.style.width = newW + 'px';
|
||||
panel.style.minWidth = newW + 'px';
|
||||
PanelRegistry.register('preview', {
|
||||
element: el,
|
||||
label: 'Preview',
|
||||
actions: [
|
||||
{
|
||||
id: 'sidePanelClearBtn',
|
||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
|
||||
title: 'Clear preview',
|
||||
onClickName: 'clearPreview()',
|
||||
visible: _hasPreviewContent,
|
||||
},
|
||||
],
|
||||
saveState() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
return {
|
||||
srcdoc: frame?.srcdoc || '',
|
||||
hasContent: frame?.style.display !== 'none',
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
panel.style.transition = ''; // re-enable animation
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
},
|
||||
restoreState(state) {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame && state.srcdoc) {
|
||||
frame.srcdoc = state.srcdoc;
|
||||
frame.style.display = state.hasContent ? '' : 'none';
|
||||
if (empty) empty.style.display = state.hasContent ? 'none' : '';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function _hasPreviewContent() {
|
||||
const frame = document.getElementById('previewFrame');
|
||||
return frame && frame.style.display !== 'none' && frame.srcdoc;
|
||||
}
|
||||
|
||||
// ── Pop-out for Extension-Rendered Blocks ───
|
||||
|
||||
/**
|
||||
* Open an extension-rendered block (Mermaid, KaTeX, etc.) in the
|
||||
* preview panel at full size.
|
||||
*/
|
||||
function popOutExtBlock(blockId) {
|
||||
const block = document.querySelector(`[data-ext-block="${blockId}"]`);
|
||||
if (!block) return;
|
||||
|
||||
// Clone rendered content (skip the pop-out button itself)
|
||||
const clone = block.cloneNode(true);
|
||||
const btn = clone.querySelector('.ext-popout-btn');
|
||||
if (btn) btn.remove();
|
||||
|
||||
const lang = block.getAttribute('data-ext-lang') || '';
|
||||
|
||||
// Wrap in a minimal HTML document for the iframe
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||||
align-items: flex-start; min-height: 100vh; background: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
body > * { max-width: 100%; }
|
||||
svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head><body>${clone.innerHTML}</body></html>`;
|
||||
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame) {
|
||||
frame.srcdoc = html;
|
||||
frame.style.display = '';
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
PanelRegistry.open('preview');
|
||||
PanelRegistry.refreshActions();
|
||||
}
|
||||
|
||||
// ── Live Preview During Streaming ───────────
|
||||
|
||||
let _livePreviewTimer = null;
|
||||
|
||||
/**
|
||||
* Called on each streaming delta with the accumulated raw markdown
|
||||
* content. If the preview panel is open, extracts the last completed
|
||||
* HTML code block and updates the iframe. Debounced at 500ms.
|
||||
*/
|
||||
function _livePreviewUpdate(rawContent) {
|
||||
// Only live-update if preview is the active panel
|
||||
if (!PanelRegistry.isOpen('preview')) return;
|
||||
|
||||
clearTimeout(_livePreviewTimer);
|
||||
_livePreviewTimer = setTimeout(() => {
|
||||
const html = _extractLastHTMLBlock(rawContent);
|
||||
if (!html) return;
|
||||
|
||||
const frame = document.getElementById('previewFrame');
|
||||
const empty = document.getElementById('previewEmpty');
|
||||
if (frame) {
|
||||
frame.srcdoc = html;
|
||||
frame.style.display = '';
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
PanelRegistry.refreshActions();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last completed fenced HTML code block from raw markdown.
|
||||
* Returns the raw HTML content or null if none found.
|
||||
*
|
||||
* "Completed" means both opening ``` and closing ``` are present.
|
||||
*/
|
||||
function _extractLastHTMLBlock(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// Match all completed ```html ... ``` blocks (or ```htm, or untagged that look like HTML)
|
||||
const pattern = /```(?:html|htm)?\s*\n([\s\S]*?)```/gi;
|
||||
let lastMatch = null;
|
||||
let m;
|
||||
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
const blockContent = m[1];
|
||||
const lang = m[0].match(/```(\w*)/)?.[1]?.toLowerCase() || '';
|
||||
if (lang === 'html' || lang === 'htm' || _looksLikeHTML(blockContent)) {
|
||||
lastMatch = blockContent;
|
||||
}
|
||||
}
|
||||
|
||||
return lastMatch;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// Render tool calls from stored message data (history view)
|
||||
|
||||
Reference in New Issue
Block a user