aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/floating.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ext/floating.js')
-rw-r--r--modules/ext/floating.js331
1 files changed, 331 insertions, 0 deletions
diff --git a/modules/ext/floating.js b/modules/ext/floating.js
new file mode 100644
index 0000000..b6f5a93
--- /dev/null
+++ b/modules/ext/floating.js
@@ -0,0 +1,331 @@
+// FloatingModule — Remixable floating UI elements (buttons, menus, panels)
+// Persisted in localStorage. Created/modified/removed via terminal commands.
+// Accessible to AI via normal run_command path.
+
+class FloatingModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._elements = {};
+ this._storageKey = 'webx11_floating_elements';
+ }
+
+ requirements() { return { met: true, missing: [] }; }
+
+ getHelpInfo() {
+ return {
+ 'floating-add': 'floating-add btn|menu|panel <id> <label> [command|buttons...] — Add a floating UI element',
+ 'floating-remove': 'floating-remove <id> — Remove a floating element',
+ 'floating-list': 'floating-list — Show all floating elements',
+ 'floating-pos': 'floating-pos <id> <x|auto> <y|auto> — Reposition a floating element',
+ 'floating-clear': 'floating-clear — Remove all floating elements',
+ 'floating-toggle': 'floating-toggle <id> — Show/hide a floating element',
+ };
+ }
+
+ registerCommands() {
+ this.commands['floating-add'] = (args) => this._cmdAdd(args);
+ this.commands['floating-remove'] = (args) => this._cmdRemove(args);
+ this.commands['floating-list'] = () => this._cmdList();
+ this.commands['floating-pos'] = (args) => this._cmdPos(args);
+ this.commands['floating-clear'] = () => this._cmdClear();
+ this.commands['floating-toggle'] = (args) => this._cmdToggle(args);
+ }
+
+ seed() {
+ this._restore();
+ }
+
+ // ── Persistence ──
+
+ _save() {
+ const data = { version: 1, elements: {} };
+ for (const [id, el] of Object.entries(this._elements)) {
+ data.elements[id] = {
+ type: el.type,
+ label: el.label,
+ command: el.command,
+ buttons: el.buttons,
+ content: el.content,
+ x: el.x,
+ y: el.y,
+ visible: el.visible !== false,
+ collapsed: el.collapsed !== false
+ };
+ }
+ try {
+ localStorage.setItem(this._storageKey, JSON.stringify(data));
+ } catch (e) {
+ console.warn('FloatingModule: save failed', e);
+ }
+ }
+
+ _restore() {
+ try {
+ const raw = localStorage.getItem(this._storageKey);
+ if (!raw) return;
+ const data = JSON.parse(raw);
+ if (!data.elements) return;
+ for (const [id, cfg] of Object.entries(data.elements)) {
+ this._elements[id] = {
+ id,
+ type: cfg.type,
+ label: cfg.label,
+ command: cfg.command || '',
+ buttons: cfg.buttons || [],
+ content: cfg.content || '',
+ x: cfg.x != null ? cfg.x : null,
+ y: cfg.y != null ? cfg.y : null,
+ visible: cfg.visible !== false,
+ collapsed: cfg.collapsed !== false
+ };
+ }
+ } catch (e) {
+ console.warn('FloatingModule: failed to restore', e);
+ }
+ this._renderAll();
+ }
+
+ // ── Rendering ──
+
+ _getContainer() {
+ let container = document.getElementById('floating-container');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'floating-container';
+ const ref = document.getElementById('ai-confirm-area') || document.getElementById('input-area');
+ const parent = document.body;
+ if (ref && ref.parentNode) {
+ parent.insertBefore(container, ref);
+ } else {
+ parent.appendChild(container);
+ }
+ }
+ return container;
+ }
+
+ _renderAll() {
+ const container = this._getContainer();
+ container.innerHTML = '';
+ for (const [id, el] of Object.entries(this._elements)) {
+ if (!el.visible) continue;
+ const node = this._renderElement(el);
+ if (!node) continue;
+ node.dataset.floatingId = id;
+ if (el.x != null && el.y != null) {
+ node.style.position = 'fixed';
+ node.style.left = el.x + 'px';
+ node.style.top = el.y + 'px';
+ node.style.bottom = 'auto';
+ node.style.right = 'auto';
+ }
+ container.appendChild(node);
+ }
+ }
+
+ _renderElement(el) {
+ switch (el.type) {
+ case 'button': return this._renderButton(el);
+ case 'menu': return this._renderMenu(el);
+ case 'panel': return this._renderPanel(el);
+ default: return null;
+ }
+ }
+
+ _renderButton(el) {
+ const btn = document.createElement('button');
+ btn.className = 'floating-btn';
+ btn.innerHTML = el.label;
+ btn.onclick = (e) => {
+ e.stopPropagation();
+ if (el.command && this.terminal) {
+ this.terminal.executeCommand(el.command);
+ }
+ };
+ return btn;
+ }
+
+ _renderMenu(el) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'floating-menu';
+
+ const header = document.createElement('div');
+ header.className = 'floating-menu-header';
+ header.innerHTML = `<span class="arrow ${el.collapsed ? 'collapsed' : ''}">▼</span> ${el.label}`;
+
+ const body = document.createElement('div');
+ body.className = 'floating-menu-body' + (el.collapsed ? '' : ' expanded');
+
+ (el.buttons || []).forEach(sub => {
+ const subBtn = document.createElement('button');
+ subBtn.className = 'floating-menu-btn';
+ subBtn.textContent = sub.label;
+ subBtn.onclick = (e) => {
+ e.stopPropagation();
+ if (sub.command && this.terminal) {
+ this.terminal.executeCommand(sub.command);
+ }
+ };
+ body.appendChild(subBtn);
+ });
+
+ header.onclick = () => {
+ el.collapsed = !el.collapsed;
+ const arrow = header.querySelector('.arrow');
+ if (arrow) arrow.classList.toggle('collapsed', el.collapsed);
+ body.classList.toggle('expanded', !el.collapsed);
+ this._save();
+ };
+
+ wrapper.appendChild(header);
+ wrapper.appendChild(body);
+ return wrapper;
+ }
+
+ _renderPanel(el) {
+ const panel = document.createElement('div');
+ panel.className = 'floating-panel';
+ panel.innerHTML = `
+ <div class="floating-panel-title">${this._escapeHtml(el.label)}</div>
+ <div class="floating-panel-content">${this._escapeHtml(el.content)}</div>
+ `;
+ return panel;
+ }
+
+ _escapeHtml(str) {
+ if (!str) return '';
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
+ }
+
+ // ── Commands ──
+
+ _cmdAdd(args) {
+ if (args.length < 3) {
+ this.terminal.print('Usage: floating-add btn|menu|panel <id> <label> [command|btn:Label|cmd...]', 'warning');
+ return;
+ }
+ const type = args[0];
+ const id = args[1];
+ const label = args[2];
+
+ if (this._elements[id]) {
+ this.terminal.print(`Element '${id}' already exists`, 'warning');
+ return;
+ }
+
+ const el = { id, type, label, x: null, y: null, visible: true };
+
+ switch (type) {
+ case 'btn':
+ case 'button':
+ if (args.length < 4) {
+ this.terminal.print('Usage: floating-add btn <id> <label> <command>', 'warning');
+ return;
+ }
+ el.type = 'button';
+ el.command = args.slice(3).join(' ');
+ break;
+ case 'menu':
+ el.type = 'menu';
+ el.collapsed = true;
+ el.buttons = [];
+ for (let i = 3; i < args.length; i++) {
+ const m = args[i].match(/^btn:(.+?)\|(.+)$/);
+ if (m) {
+ el.buttons.push({ label: m[1], command: m[2] });
+ }
+ }
+ if (el.buttons.length === 0) {
+ this.terminal.print('No valid sub-buttons. Use btn:Label|command format', 'warning');
+ return;
+ }
+ break;
+ case 'panel':
+ el.type = 'panel';
+ el.content = args.slice(3).join(' ') || '';
+ break;
+ default:
+ this.terminal.print(`Unknown type '${type}'. Use: btn, menu, panel`, 'warning');
+ return;
+ }
+
+ this._elements[id] = el;
+ this._save();
+ this._renderAll();
+ this.terminal.print(`Floating '${type}' '${label}' created (id: ${id})`, 'success');
+ }
+
+ _cmdRemove(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: floating-remove <id>', 'warning');
+ return;
+ }
+ if (!this._elements[args[0]]) {
+ this.terminal.print(`Element '${args[0]}' not found`, 'warning');
+ return;
+ }
+ delete this._elements[args[0]];
+ this._save();
+ this._renderAll();
+ this.terminal.print(`Element '${args[0]}' removed`, 'success');
+ }
+
+ _cmdList() {
+ const ids = Object.keys(this._elements);
+ if (ids.length === 0) {
+ this.terminal.print('No floating elements', 'info');
+ return;
+ }
+ this.terminal.print(`Floating elements (${ids.length}):`, 'info');
+ for (const id of ids) {
+ const el = this._elements[id];
+ const pos = (el.x != null) ? `${el.x},${el.y}` : 'auto';
+ const vis = el.visible ? 'visible' : 'hidden';
+ this.terminal.print(` ${id}: ${el.type} "${el.label}" @${pos} [${vis}]`, 'text-muted');
+ if (el.type === 'menu' && el.buttons) {
+ el.buttons.forEach(b => this.terminal.print(` → btn:${b.label}|${b.command}`, 'text-muted'));
+ }
+ }
+ }
+
+ _cmdPos(args) {
+ if (args.length < 3) {
+ this.terminal.print('Usage: floating-pos <id> <x|auto> <y|auto>', 'warning');
+ return;
+ }
+ const id = args[0];
+ if (!this._elements[id]) {
+ this.terminal.print(`Element '${id}' not found`, 'warning');
+ return;
+ }
+ this._elements[id].x = args[1] === 'auto' ? null : parseInt(args[1]);
+ this._elements[id].y = args[2] === 'auto' ? null : parseInt(args[2]);
+ this._save();
+ this._renderAll();
+ this.terminal.print(`Element '${id}' repositioned`, 'success');
+ }
+
+ _cmdToggle(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: floating-toggle <id>', 'warning');
+ return;
+ }
+ const id = args[0];
+ if (!this._elements[id]) {
+ this.terminal.print(`Element '${id}' not found`, 'warning');
+ return;
+ }
+ this._elements[id].visible = !this._elements[id].visible;
+ this._save();
+ this._renderAll();
+ this.terminal.print(`Element '${id}' now ${this._elements[id].visible ? 'visible' : 'hidden'}`, 'success');
+ }
+
+ _cmdClear() {
+ this._elements = {};
+ this._save();
+ this._renderAll();
+ this.terminal.print('All floating elements cleared', 'success');
+ }
+} \ No newline at end of file