diff options
Diffstat (limited to 'modules/ext/floating.js')
| -rw-r--r-- | modules/ext/floating.js | 331 |
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, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + // ── 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 |