Changeset 0.21.3 (#89)

This commit is contained in:
2026-03-01 18:20:01 +00:00
parent c5159538ce
commit bbbbe65bfa
10 changed files with 1098 additions and 23 deletions

319
src/js/surfaces.js Normal file
View File

@@ -0,0 +1,319 @@
// ==========================================
// Chat Switchboard Surface Registry
// ==========================================
// Manages "modes" (surfaces) — chat, editor, article, etc.
// Each surface can take over named regions of the UI without
// destroying the DOM nodes of the previous surface.
//
// Load order: events.js → surfaces.js → extensions.js
//
// Key design: replace() detaches children (kept in memory),
// restore() re-attaches them. CM6 editor instances survive
// mode switches because their DOM isn't destroyed.
// ==========================================
const Surfaces = {
// ── State ────────────────────────────────
_registry: new Map(), // surfaceId → { label, icon, regions, activate, deactivate }
_current: 'chat', // active surface id
_saved: new Map(), // regionId → DocumentFragment (saved DOM children)
_regionEls: new Map(), // regionId → DOM element (cached lookups)
// ── Initialization ───────────────────────
/**
* Cache region elements and register chat as the implicit default surface.
* Called from app.js init after DOM is ready.
*/
init() {
// Cache all region containers
document.querySelectorAll('[data-surface-region]').forEach(el => {
const id = el.dataset.surfaceRegion;
this._regionEls.set(id, el);
});
// Chat is always registered as the default surface
this._registry.set('chat', {
label: 'Chat',
icon: 'message-square',
regions: ['surface-header', 'surface-main', 'surface-footer'],
activate: null, // chat activation is implicit (restore regions)
deactivate: null,
_isDefault: true,
});
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
},
// ── Registration ─────────────────────────
/**
* Register a new surface (mode).
* @param {string} id — unique surface identifier
* @param {object} opts — { label, icon, regions[], activate(), deactivate() }
*/
register(id, opts = {}) {
if (this._registry.has(id)) {
console.warn(`[Surfaces] ${id} already registered`);
return;
}
this._registry.set(id, {
label: opts.label || id,
icon: opts.icon || 'layout',
regions: opts.regions || [],
activate: opts.activate || null,
deactivate: opts.deactivate || null,
});
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
// Show mode selector if we now have >1 surface
this._updateModeSelector();
Events.emit('surface.registered', { surface: id }, { localOnly: true });
},
/**
* Unregister a surface. If it's currently active, switch back to chat.
*/
unregister(id) {
if (id === 'chat') return; // can't unregister chat
if (!this._registry.has(id)) return;
if (this._current === id) {
this.activate('chat');
}
this._registry.delete(id);
this._updateModeSelector();
Events.emit('surface.unregistered', { surface: id }, { localOnly: true });
},
// ── Activation ───────────────────────────
/**
* Switch to a different surface.
* Deactivates the current surface, saves its region DOM, and activates the new one.
*/
activate(id) {
if (!this._registry.has(id)) {
console.error(`[Surfaces] Unknown surface: ${id}`);
return;
}
if (this._current === id) return;
const previous = this._current;
const prevDef = this._registry.get(previous);
const nextDef = this._registry.get(id);
// Deactivate current surface
if (prevDef) {
// Save current region contents
for (const regionId of (prevDef.regions || [])) {
this._saveRegion(regionId);
}
// Call surface-specific deactivation
if (typeof prevDef.deactivate === 'function') {
try { prevDef.deactivate(); } catch (e) {
console.error(`[Surfaces] deactivate ${previous}:`, e);
}
}
}
Events.emit('surface.deactivated', { surface: previous }, { localOnly: true });
this._current = id;
// Activate new surface
if (typeof nextDef.activate === 'function') {
try { nextDef.activate(); } catch (e) {
console.error(`[Surfaces] activate ${id}:`, e);
}
} else {
// Default behavior: restore saved DOM for this surface's regions
for (const regionId of (nextDef.regions || [])) {
this._restoreRegion(regionId);
}
}
// Update mode selector active state
this._updateModeSelectorActive();
Events.emit('surface.activated', { surface: id, previous }, { localOnly: true });
console.log(`[Surfaces] Activated: ${id} (was: ${previous})`);
},
// ── Query ────────────────────────────────
/** Get the currently active surface id. */
getCurrent() {
return this._current;
},
/** Get surface definition by id. */
get(id) {
return this._registry.get(id) || null;
},
/** Get all registered surface ids. */
list() {
return Array.from(this._registry.keys());
},
/** True when more than just chat is registered. */
hasMultiple() {
return this._registry.size > 1;
},
// ── Region Management ────────────────────
/**
* Replace a region's content with a new element.
* The current children are saved (detached, not destroyed) and can be
* restored later with restore(). This is critical for CM6 state preservation.
*
* @param {string} regionId — data-surface-region value
* @param {Element} element — new content to insert
*/
replace(regionId, element) {
const container = this._regionEls.get(regionId);
if (!container) {
console.warn(`[Surfaces] Unknown region: ${regionId}`);
return;
}
// Save current children to a DocumentFragment (preserves DOM state)
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
// Insert new content
if (element) {
container.appendChild(element);
}
},
/**
* Restore a region's previously saved content.
* @param {string} regionId — data-surface-region value
*/
restore(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear current contents
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Re-attach saved DOM
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Internal: Save/Restore Regions ───────
/**
* Save the current DOM children of a region for the active surface.
* Called during deactivation.
*/
_saveRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = document.createDocumentFragment();
while (container.firstChild) {
frag.appendChild(container.firstChild);
}
this._saved.set(key, frag);
},
/**
* Restore the saved DOM children of a region for the active surface.
* Called during activation.
*/
_restoreRegion(regionId) {
const container = this._regionEls.get(regionId);
if (!container) return;
const key = `${this._current}::${regionId}`;
const frag = this._saved.get(key);
// Clear container
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Restore saved content
if (frag) {
container.appendChild(frag);
this._saved.delete(key);
}
},
// ── Mode Selector UI ────────────────────
/**
* Rebuild the mode selector in the sidebar.
* Shown only when ≥1 extension surface is registered (i.e. more than just chat).
*/
_updateModeSelector() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
if (this._registry.size <= 1) {
wrap.style.display = 'none';
wrap.innerHTML = '';
return;
}
wrap.style.display = '';
wrap.innerHTML = '';
for (const [id, def] of this._registry) {
const btn = document.createElement('button');
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
btn.dataset.surface = id;
btn.title = def.label;
btn.innerHTML = this._iconSvg(def.icon);
btn.addEventListener('click', () => this.activate(id));
wrap.appendChild(btn);
}
},
/** Update the active class on mode selector buttons. */
_updateModeSelectorActive() {
const wrap = document.getElementById('modeSelectorWrap');
if (!wrap) return;
wrap.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.surface === this._current);
});
},
/**
* Return an SVG string for a lucide-style icon name.
* Only the icons we actually need are included here.
*/
_iconSvg(name) {
const icons = {
'message-square': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
'code': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
'file-text': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
'layout': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
'terminal': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
'globe': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
};
return icons[name] || icons['layout'];
},
};