// static/js/spinner.js /** * ASCII Spinner Module for AI thinking/processing status */ class Spinner { constructor(message = "AI is processing", style = "right", animation = "spinner") { // Different animation frames this.animations = { spinner: ['|', '/', '-', '\\'], wave: ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▅', '▆▅▄', '▅▄▃', '▄▃▂', '▃▂▁'] }; this.animation = animation; this.frames = this.animations[animation] || this.animations.spinner; this.message = message; this.style = style; // "left", "right", or "clean" this.isRunning = false; this.currentFrame = 0; this.intervalId = null; this.rafId = null; this.element = null; } /** * Create and return the spinner HTML element */ createElement() { if (this.animation === 'sinewave') { return this._createSineWaveElement(); } if (this.animation === 'whirlpool') { return this._createWhirlpoolElement(); } const span = document.createElement('span'); span.className = 'ai-spinner'; span.style.cssText = 'font-family: monospace; white-space: pre;'; this.element = span; this.updateDisplay(); return span; } _createSineWaveElement() { const wrapper = document.createElement('span'); wrapper.className = 'ai-spinner ai-spinner-sinewave'; wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;'; const canvas = document.createElement('canvas'); canvas.width = 50; canvas.height = 18; canvas.style.cssText = 'display: inline-block; vertical-align: middle;'; const msgSpan = document.createElement('span'); msgSpan.textContent = this.message; this._msgSpan = msgSpan; if (this.style === 'left') { wrapper.appendChild(canvas); wrapper.appendChild(msgSpan); } else if (this.style === 'right') { wrapper.appendChild(msgSpan); wrapper.appendChild(canvas); } else { wrapper.appendChild(msgSpan); } this._canvas = canvas; this._ctx = canvas.getContext('2d'); this._waveT = 0; this._wavePrev = performance.now(); this.element = wrapper; return wrapper; } _drawSineWave() { const ctx = this._ctx; const W = this._canvas.width; const H = this._canvas.height; const midY = H / 2; const AMP = 7; const CYCLES = 2.5; const PAD = 3; const trackW = W - 2 * PAD; const BASE_SPEED = 0.44; const MIN_SPEED = 0.4; const MAX_SPEED = 2.5; const now = performance.now(); const dt = (now - this._wavePrev) / 1000; this._wavePrev = now; const dotPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT; const norm = (1 + Math.sin(dotPhase)) / 2; const speedMul = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * Math.pow(norm, 1.3); this._waveT += dt * BASE_SPEED * speedMul * CYCLES * 2 * Math.PI; ctx.clearRect(0, 0, W, H); // wave line ctx.beginPath(); for (let i = 0; i <= 80; i++) { const frac = i / 80; const x = PAD + frac * trackW; const phase = frac * CYCLES * 2 * Math.PI + this._waveT; const y = midY + Math.sin(phase) * AMP; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = 'rgba(156, 222, 242, 0.5)'; ctx.lineWidth = 1.5; ctx.stroke(); // dot const cx = W / 2; const cPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT; const cy = midY + Math.sin(cPhase) * AMP; ctx.beginPath(); ctx.arc(cx, cy, 1.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(156, 222, 242, 0.9)'; ctx.fill(); if (this.isRunning) { this.rafId = requestAnimationFrame(() => this._drawSineWave()); } } _createWhirlpoolElement() { const wrapper = document.createElement('span'); wrapper.className = 'ai-spinner ai-spinner-whirlpool'; wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;'; const size = this._wpSize || 18; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; canvas.style.cssText = 'display: inline-block; vertical-align: middle;'; const msgSpan = document.createElement('span'); msgSpan.textContent = this.message; this._msgSpan = msgSpan; if (this.style === 'left') { wrapper.appendChild(canvas); wrapper.appendChild(msgSpan); } else if (this.style === 'right') { wrapper.appendChild(msgSpan); wrapper.appendChild(canvas); } else { wrapper.appendChild(canvas); } this._wpCanvas = canvas; this._wpCtx = canvas.getContext('2d'); this._wpStartedAt = null; this.element = wrapper; return wrapper; } _drawWhirlpool() { const ctx = this._wpCtx; const W = this._wpCanvas.width; const H = this._wpCanvas.height; const cx = W / 2, cy = H / 2; const maxR = Math.min(W, H) / 2 - 1; const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5; const TOTAL_TURNS = 2.7; const STEPS = 84; const LOOP_MS = 1100; if (!this._wpStartedAt) this._wpStartedAt = performance.now(); const loop = ((performance.now() - this._wpStartedAt) % LOOP_MS) / LOOP_MS; const rot = loop * Math.PI * 2; // Colors from CSS vars — read ONCE and cache. Calling getComputedStyle every // frame forces a full style recalc per frame, which janks/freezes the canvas // animation badly when it's painting over a heavy photo. (Theme changes are // rare; the spinner is short-lived, so a stale cache is fine.) if (!this._wpColors) { const s = getComputedStyle(document.documentElement); this._wpColors = { fg: s.getPropertyValue('--red').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2', track: s.getPropertyValue('--border').trim() || '#355a66', }; } const fg = this._wpColors.fg; const track = this._wpColors.track; function spiralPoint(frac) { const eased = Math.pow(frac, 0.82); const r = maxR * eased; const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot; return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }; } ctx.clearRect(0, 0, W, H); // track ring ctx.beginPath(); ctx.arc(cx, cy, maxR - lw / 2, 0, Math.PI * 2); ctx.strokeStyle = track; ctx.lineWidth = lw; ctx.globalAlpha = 0.35; ctx.stroke(); ctx.globalAlpha = 1; // Rotating a single continuous spiral keeps the loop seamless: the start // and end frames are the same shape, just one full turn apart. ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (let i = 1; i <= STEPS; i++) { const a = (i - 1) / STEPS; const b = i / STEPS; const p0 = spiralPoint(a); const p1 = spiralPoint(b); ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.strokeStyle = fg; ctx.lineWidth = lw * (0.52 + b * 0.32); ctx.globalAlpha = 0.12 + Math.pow(b, 1.8) * 0.72; ctx.stroke(); } const head = spiralPoint(1); ctx.beginPath(); ctx.arc(head.x, head.y, Math.max(1.05, lw * 0.48), 0, Math.PI * 2); ctx.fillStyle = fg; ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1; if (!this.isRunning) return; // Leak-safe self-terminate: stop once our element WAS in the DOM and then // got removed (e.g. a loading row replaced by results). But keep spinning // before it's first appended — start() runs synchronously, before the // caller inserts the element, so it isn't connected on frame 1. const connected = !!(this.element && this.element.isConnected); if (connected) this._wpWasConnected = true; if (connected || !this._wpWasConnected) { this.rafId = requestAnimationFrame(() => this._drawWhirlpool()); } else { this.isRunning = false; } } /** * Update the spinner display */ updateDisplay() { if (!this.element) return; const frame = this.frames[this.currentFrame % this.frames.length]; let display = ''; if (this.style === "left") { display = `${frame} ${this.message}`; } else if (this.style === "right") { display = `${this.message} ${frame}`; } else { // clean display = this.message; } this.element.innerHTML = display; } /** * Start the spinner animation */ start(speed = 150) { if (this.isRunning) return; this.isRunning = true; if (this.animation === 'sinewave') { this._wavePrev = performance.now(); this._drawSineWave(); return; } if (this.animation === 'whirlpool') { this._wpStartedAt = performance.now(); this._drawWhirlpool(); return; } this.currentFrame = 0; this.intervalId = setInterval(() => { this.currentFrame++; this.updateDisplay(); }, speed); } /** * Stop the spinner */ stop() { this.isRunning = false; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } } /** * Update the message while spinner is running */ updateMessage(newMessage) { this.message = newMessage; if ((this.animation === 'sinewave' || this.animation === 'whirlpool') && this._msgSpan) { this._msgSpan.textContent = newMessage; } else { this.updateDisplay(); } } /** * Update the spinner label text */ updateLabel(newMessage) { this.message = newMessage; if (this._msgSpan) { this._msgSpan.textContent = newMessage; } else { this.updateDisplay(); } } /** * Destroy the spinner and clean up */ destroy() { this.stop(); if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } this.element = null; } } /** * Create a new spinner instance */ export function create(message, style = "right", animation = "wave") { return new Spinner(message, style, animation); } /** * Create a standalone whirlpool circle spinner (replaces CSS .spinner) * Returns { element, start(), stop(), destroy() } */ export function createWhirlpool(size = 24) { const sp = new Spinner('', 'clean', 'whirlpool'); sp._wpSize = size; const el = sp.createElement(); // wrap in a div matching .spinner layout const wrap = document.createElement('div'); wrap.className = 'spinner-whirlpool'; wrap.style.cssText = `width:${size}px;height:${size}px;margin:8px auto;`; wrap.appendChild(el); sp.start(); return { element: wrap, stop: () => sp.stop(), destroy: () => sp.destroy() }; } /** * A consistent inline loading row for list/library empty-states: a label plus * the whirlpool spinner. Returns a detached element; the spinner self-stops * once the element leaves the DOM (see _drawWhirlpool), so callers can just * replace it with results — no manual cleanup needed. */ export function createLoadingRow(text = 'Loading…', size = 16) { const sp = new Spinner('', 'clean', 'whirlpool'); sp._wpSize = size; const canvas = sp.createElement(); const row = document.createElement('div'); row.className = 'lib-loading-row'; const label = document.createElement('span'); label.textContent = text; row.appendChild(label); row.appendChild(canvas); sp.start(); return row; } export { Spinner }; const spinnerModule = { create, createWhirlpool, createLoadingRow, Spinner }; export default spinnerModule;