// 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._wpFrame = 60; 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 = 4; const TAIL_LEN = 0.45; const SPIN_SPEED = 0.08; const LAYERS = 12; const STEPS = 50; const t = this._wpFrame; // 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, rot) { const r = maxR * (1 - frac); 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; const headPos = (t * 0.008) % 1; // overlapping sub-paths for smooth fade ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (let layer = LAYERS - 1; layer >= 0; layer--) { const endFrac = (layer + 1) / LAYERS; const stepsForLayer = Math.ceil(STEPS * endFrac); const alpha = Math.pow(1 - endFrac, 2) * 0.7; ctx.beginPath(); let started = false; let prevPos = -1; for (let i = 0; i <= stepsForLayer; i++) { const frac = i / STEPS; let pos = headPos - frac * TAIL_LEN; if (pos < 0) pos += 1; if (started && prevPos < 0.3 && pos > 0.7) { ctx.stroke(); ctx.beginPath(); started = false; } const pt = spiralPoint(pos, t * SPIN_SPEED); if (!started) { ctx.moveTo(pt.x, pt.y); started = true; } else ctx.lineTo(pt.x, pt.y); prevPos = pos; } ctx.strokeStyle = fg; ctx.lineWidth = lw * 0.8; ctx.globalAlpha = alpha; ctx.stroke(); } // bright dot at head const head = spiralPoint(headPos, t * SPIN_SPEED); ctx.beginPath(); ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2); ctx.fillStyle = fg; ctx.globalAlpha = 0.9; ctx.fill(); ctx.globalAlpha = 1; this._wpFrame++; 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._wpFrame = 60; 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;