diff --git a/src/snek/static/toolbar-menu.js b/src/snek/static/toolbar-menu.js new file mode 100644 index 0000000..6dfdca0 --- /dev/null +++ b/src/snek/static/toolbar-menu.js @@ -0,0 +1,235 @@ +// retoor + +class ToolbarMenu extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._isOpen = false; + this._sttButton = null; + this._ttsButton = null; + this._uploadButton = null; + this._observer = null; + this._boundClickOutside = this._handleClickOutside.bind(this); + + const style = document.createElement('style'); + style.textContent = ` + :host { + display: inline-block; + position: relative; + } + + .toolbar-container { + position: relative; + } + + .menu-toggle { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + margin-left: 10px; + background-color: #000000; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 18px; + position: relative; + overflow: visible; + transition: background-color 0.2s ease, transform 0.1s ease; + } + + .menu-toggle:hover { + background-color: #222222; + } + + .menu-toggle:active { + transform: scale(0.95); + } + + :host([open]) .menu-toggle { + background-color: #222222; + } + + .status-indicator { + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + display: none; + } + + .status-indicator.listening { + display: block; + background-color: #d32f2f; + animation: pulse-indicator 1.5s ease-in-out infinite; + } + + .status-indicator.tts-enabled { + display: block; + background-color: #2e7d32; + } + + @keyframes pulse-indicator { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(211, 47, 47, 0.6); + } + 50% { + box-shadow: 0 0 0 4px rgba(211, 47, 47, 0); + } + } + + .menu-panel { + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 8px; + background-color: #111111; + border: 1px solid #333333; + border-radius: 8px; + padding: 8px; + display: none; + flex-direction: column; + gap: 8px; + min-width: 60px; + box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4); + z-index: 1000; + } + + :host([open]) .menu-panel { + display: flex; + } + + .menu-item { + display: flex; + justify-content: center; + } + `; + + const container = document.createElement('div'); + container.className = 'toolbar-container'; + + this._menuPanel = document.createElement('div'); + this._menuPanel.className = 'menu-panel'; + + this._toggleButton = document.createElement('button'); + this._toggleButton.className = 'menu-toggle'; + this._toggleButton.setAttribute('aria-label', 'Toggle toolbar menu'); + this._toggleButton.innerHTML = '☰'; + + this._statusIndicator = document.createElement('span'); + this._statusIndicator.className = 'status-indicator'; + this._toggleButton.appendChild(this._statusIndicator); + + this._toggleButton.addEventListener('click', (e) => { + e.stopPropagation(); + this._toggle(); + }); + + container.appendChild(this._menuPanel); + container.appendChild(this._toggleButton); + this.shadowRoot.append(style, container); + } + + _toggle() { + if (this._isOpen) { + this._close(); + } else { + this._open(); + } + } + + _open() { + this._isOpen = true; + this.setAttribute('open', ''); + setTimeout(() => { + document.addEventListener('click', this._boundClickOutside); + }, 0); + } + + _close() { + this._isOpen = false; + this.removeAttribute('open'); + document.removeEventListener('click', this._boundClickOutside); + } + + _handleClickOutside(event) { + const path = event.composedPath(); + if (!path.includes(this)) { + this._close(); + } + } + + _observeButtonStates() { + if (this._observer) { + this._observer.disconnect(); + } + + this._observer = new MutationObserver(() => { + this._updateStatusIndicator(); + }); + + if (this._sttButton) { + this._observer.observe(this._sttButton, { + attributes: true, + attributeFilter: ['listening'] + }); + } + + if (this._ttsButton) { + this._observer.observe(this._ttsButton, { + attributes: true, + attributeFilter: ['enabled'] + }); + } + + this._updateStatusIndicator(); + } + + _updateStatusIndicator() { + this._statusIndicator.classList.remove('listening', 'tts-enabled'); + + if (this._sttButton?.hasAttribute('listening')) { + this._statusIndicator.classList.add('listening'); + } else if (this._ttsButton?.hasAttribute('enabled')) { + this._statusIndicator.classList.add('tts-enabled'); + } + } + + addButton(button, type) { + const wrapper = document.createElement('div'); + wrapper.className = 'menu-item'; + + if (button.style) { + button.style.marginLeft = '0'; + } + + wrapper.appendChild(button); + this._menuPanel.appendChild(wrapper); + + if (type === 'stt') { + this._sttButton = button; + } else if (type === 'tts') { + this._ttsButton = button; + } else if (type === 'upload') { + this._uploadButton = button; + } + + this._observeButtonStates(); + } + + connectedCallback() { + this._observeButtonStates(); + } + + disconnectedCallback() { + document.removeEventListener('click', this._boundClickOutside); + if (this._observer) { + this._observer.disconnect(); + } + } +} + +customElements.define('toolbar-menu', ToolbarMenu);