342 lines
13 KiB
JavaScript
342 lines
13 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Surface Registry
|
||
// ==========================================
|
||
// Manages "modes" (surfaces) — chat, editor, 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;
|
||
},
|
||
|
||
/**
|
||
* Get a saved DocumentFragment for a specific surface + region.
|
||
* Used by surfaces like editor-mode that want to embed chat DOM
|
||
* inside their own layout.
|
||
* @param {string} surfaceId — the surface that owns the saved DOM
|
||
* @param {string} regionId — the region name
|
||
* @returns {DocumentFragment|null}
|
||
*/
|
||
getSavedFragment(surfaceId, regionId) {
|
||
const key = `${surfaceId}::${regionId}`;
|
||
return this._saved.get(key) || null;
|
||
},
|
||
|
||
/**
|
||
* Put a DocumentFragment back into the saved store.
|
||
* Used during deactivation to return borrowed DOM.
|
||
*/
|
||
putSavedFragment(surfaceId, regionId, frag) {
|
||
const key = `${surfaceId}::${regionId}`;
|
||
this._saved.set(key, frag);
|
||
},
|
||
|
||
// ── 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)}<span class="mode-btn-label">${def.label}</span>`;
|
||
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'];
|
||
},
|
||
};
|