1// static/js/spinner.js 2 3/** 4 * ASCII Spinner Module for AI thinking/processing status 5 */ 6 7class Spinner { 8 constructor(message = "AI is processing", style = "right", animation = "spinner") { 9 // Different animation frames 10 this.animations = { 11 spinner: ['|', '/', '-', '\\'], 12 wave: ['âââ', 'âââ', 'âââ ', 'ââ â', 'â ââ ', 'ââ â', 'â ââ', 'âââ', 'âââ'] 13 }; 14 15 this.animation = animation; 16 this.frames = this.animations[animation] || this.animations.spinner; 17 this.message = message; 18 this.style = style; // "left", "right", or "clean" 19 this.isRunning = false; 20 this.currentFrame = 0; 21 this.intervalId = null; 22 this.rafId = null; 23 this.element = null; 24 } 25 26 /** 27 * Create and return the spinner HTML element 28 */ 29 createElement() { 30 if (this.animation === 'sinewave') { 31 return this._createSineWaveElement(); 32 } 33 if (this.animation === 'whirlpool') { 34 return this._createWhirlpoolElement(); 35 } 36 const span = document.createElement('span'); 37 span.className = 'ai-spinner'; 38 span.style.cssText = 'font-family: monospace; white-space: pre;'; 39 this.element = span; 40 this.updateDisplay(); 41 return span; 42 } 43 44 _createSineWaveElement() { 45 const wrapper = document.createElement('span'); 46 wrapper.className = 'ai-spinner ai-spinner-sinewave'; 47 wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;'; 48 49 const canvas = document.createElement('canvas'); 50 canvas.width = 50; 51 canvas.height = 18; 52 canvas.style.cssText = 'display: inline-block; vertical-align: middle;'; 53 54 const msgSpan = document.createElement('span'); 55 msgSpan.textContent = this.message; 56 this._msgSpan = msgSpan; 57 58 if (this.style === 'left') { 59 wrapper.appendChild(canvas); 60 wrapper.appendChild(msgSpan); 61 } else if (this.style === 'right') { 62 wrapper.appendChild(msgSpan); 63 wrapper.appendChild(canvas); 64 } else { 65 wrapper.appendChild(msgSpan); 66 } 67 68 this._canvas = canvas; 69 this._ctx = canvas.getContext('2d'); 70 this._waveT = 0; 71 this._wavePrev = performance.now(); 72 this.element = wrapper; 73 return wrapper; 74 } 75 76 _drawSineWave() { 77 const ctx = this._ctx; 78 const W = this._canvas.width; 79 const H = this._canvas.height; 80 const midY = H / 2; 81 const AMP = 7; 82 const CYCLES = 2.5; 83 const PAD = 3; 84 const trackW = W - 2 * PAD; 85 const BASE_SPEED = 0.44; 86 const MIN_SPEED = 0.4; 87 const MAX_SPEED = 2.5; 88 89 const now = performance.now(); 90 const dt = (now - this._wavePrev) / 1000; 91 this._wavePrev = now; 92 93 const dotPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT; 94 const norm = (1 + Math.sin(dotPhase)) / 2; 95 const speedMul = MIN_SPEED + (MAX_SPEED - MIN_SPEED) * Math.pow(norm, 1.3); 96 this._waveT += dt * BASE_SPEED * speedMul * CYCLES * 2 * Math.PI; 97 98 ctx.clearRect(0, 0, W, H); 99 100 // wave line 101 ctx.beginPath(); 102 for (let i = 0; i <= 80; i++) { 103 const frac = i / 80; 104 const x = PAD + frac * trackW; 105 const phase = frac * CYCLES * 2 * Math.PI + this._waveT; 106 const y = midY + Math.sin(phase) * AMP; 107 if (i === 0) ctx.moveTo(x, y); 108 else ctx.lineTo(x, y); 109 } 110 ctx.strokeStyle = 'rgba(156, 222, 242, 0.5)'; 111 ctx.lineWidth = 1.5; 112 ctx.stroke(); 113 114 // dot 115 const cx = W / 2; 116 const cPhase = 0.5 * CYCLES * 2 * Math.PI + this._waveT; 117 const cy = midY + Math.sin(cPhase) * AMP; 118 ctx.beginPath(); 119 ctx.arc(cx, cy, 1.5, 0, Math.PI * 2); 120 ctx.fillStyle = 'rgba(156, 222, 242, 0.9)'; 121 ctx.fill(); 122 123 if (this.isRunning) { 124 this.rafId = requestAnimationFrame(() => this._drawSineWave()); 125 } 126 } 127 128 _createWhirlpoolElement() { 129 const wrapper = document.createElement('span'); 130 wrapper.className = 'ai-spinner ai-spinner-whirlpool'; 131 wrapper.style.cssText = 'font-family: monospace; white-space: pre; display: inline-flex; align-items: center; gap: 6px;'; 132 133 const size = this._wpSize || 18; 134 const canvas = document.createElement('canvas'); 135 canvas.width = size; 136 canvas.height = size; 137 canvas.style.cssText = 'display: inline-block; vertical-align: middle;'; 138 139 const msgSpan = document.createElement('span'); 140 msgSpan.textContent = this.message; 141 this._msgSpan = msgSpan; 142 143 if (this.style === 'left') { 144 wrapper.appendChild(canvas); 145 wrapper.appendChild(msgSpan); 146 } else if (this.style === 'right') { 147 wrapper.appendChild(msgSpan); 148 wrapper.appendChild(canvas); 149 } else { 150 wrapper.appendChild(canvas); 151 } 152 153 this._wpCanvas = canvas; 154 this._wpCtx = canvas.getContext('2d'); 155 this._wpFrame = 60; 156 this.element = wrapper; 157 return wrapper; 158 } 159 160 _drawWhirlpool() { 161 const ctx = this._wpCtx; 162 const W = this._wpCanvas.width; 163 const H = this._wpCanvas.height; 164 const cx = W / 2, cy = H / 2; 165 const maxR = Math.min(W, H) / 2 - 1; 166 const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5; 167 const TOTAL_TURNS = 4; 168 const TAIL_LEN = 0.45; 169 const SPIN_SPEED = 0.08; 170 const LAYERS = 12; 171 const STEPS = 50; 172 const t = this._wpFrame; 173 174 // Colors from CSS vars â read ONCE and cache. Calling getComputedStyle every 175 // frame forces a full style recalc per frame, which janks/freezes the canvas 176 // animation badly when it's painting over a heavy photo. (Theme changes are 177 // rare; the spinner is short-lived, so a stale cache is fine.) 178 if (!this._wpColors) { 179 const s = getComputedStyle(document.documentElement); 180 this._wpColors = { 181 fg: s.getPropertyValue('--red').trim() || s.getPropertyValue('--fg').trim() || '#9cdef2', 182 track: s.getPropertyValue('--border').trim() || '#355a66', 183 }; 184 } 185 const fg = this._wpColors.fg; 186 const track = this._wpColors.track; 187 188 function spiralPoint(frac, rot) { 189 const r = maxR * (1 - frac); 190 const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot;
191 return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r }; 192 } 193 194 ctx.clearRect(0, 0, W, H); 195 196 // track ring 197 ctx.beginPath(); 198 ctx.arc(cx, cy, maxR - lw / 2, 0, Math.PI * 2); 199 ctx.strokeStyle = track; 200 ctx.lineWidth = lw; 201 ctx.globalAlpha = 0.35; 202 ctx.stroke(); 203 ctx.globalAlpha = 1; 204 205 const headPos = (t * 0.008) % 1; 206 207 // overlapping sub-paths for smooth fade 208 ctx.lineCap = 'round'; 209 ctx.lineJoin = 'round'; 210 for (let layer = LAYERS - 1; layer >= 0; layer--) { 211 const endFrac = (layer + 1) / LAYERS; 212 const stepsForLayer = Math.ceil(STEPS * endFrac); 213 const alpha = Math.pow(1 - endFrac, 2) * 0.7; 214 215 ctx.beginPath(); 216 let started = false; 217 let prevPos = -1; 218 for (let i = 0; i <= stepsForLayer; i++) { 219 const frac = i / STEPS; 220 let pos = headPos - frac * TAIL_LEN; 221 if (pos < 0) pos += 1; 222 if (started && prevPos < 0.3 && pos > 0.7) { 223 ctx.stroke(); 224 ctx.beginPath(); 225 started = false; 226 } 227 const pt = spiralPoint(pos, t * SPIN_SPEED); 228 if (!started) { ctx.moveTo(pt.x, pt.y); started = true; } 229 else ctx.lineTo(pt.x, pt.y); 230 prevPos = pos; 231 } 232 ctx.strokeStyle = fg; 233 ctx.lineWidth = lw * 0.8; 234 ctx.globalAlpha = alpha; 235 ctx.stroke(); 236 } 237 238 // bright dot at head 239 const head = spiralPoint(headPos, t * SPIN_SPEED); 240 ctx.beginPath(); 241 ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2); 242 ctx.fillStyle = fg; 243 ctx.globalAlpha = 0.9; 244 ctx.fill(); 245 ctx.globalAlpha = 1; 246 247 this._wpFrame++; 248 if (!this.isRunning) return; 249 // Leak-safe self-terminate: stop once our element WAS in the DOM and then 250 // got removed (e.g. a loading row replaced by results). But keep spinning 251 // before it's first appended â start() runs synchronously, before the 252 // caller inserts the element, so it isn't connected on frame 1. 253 const connected = !!(this.element && this.element.isConnected); 254 if (connected) this._wpWasConnected = true; 255 if (connected || !this._wpWasConnected) { 256 this.rafId = requestAnimationFrame(() => this._drawWhirlpool()); 257 } else { 258 this.isRunning = false; 259 } 260 } 261 262 /** 263 * Update the spinner display 264 */ 265 updateDisplay() { 266 if (!this.element) return; 267 268 const frame = this.frames[this.currentFrame % this.frames.length]; 269 270 let display = ''; 271 if (this.style === "left") { 272 display = `${frame} ${this.message}`; 273 } else if (this.style === "right") { 274 display = `${this.message} ${frame}`; 275 } else { // clean 276 display = this.message; 277 } 278 279 this.element.innerHTML = display; 280 } 281 282 /** 283 * Start the spinner animation 284 */ 285 start(speed = 150) { 286 if (this.isRunning) return; 287 this.isRunning = true; 288 289 if (this.animation === 'sinewave') { 290 this._wavePrev = performance.now(); 291 this._drawSineWave(); 292 return; 293 } 294 295 if (this.animation === 'whirlpool') { 296 this._wpFrame = 60; 297 this._drawWhirlpool(); 298 return; 299 } 300 301 this.currentFrame = 0; 302 this.intervalId = setInterval(() => { 303 this.currentFrame++; 304 this.updateDisplay(); 305 }, speed); 306 } 307 308 /** 309 * Stop the spinner 310 */ 311 stop() { 312 this.isRunning = false; 313 if (this.intervalId) { 314 clearInterval(this.intervalId); 315 this.intervalId = null; 316 } 317 if (this.rafId) { 318 cancelAnimationFrame(this.rafId); 319 this.rafId = null; 320 } 321 } 322 323 /** 324 * Update the message while spinner is running 325 */ 326 updateMessage(newMessage) { 327 this.message = newMessage; 328 if ((this.animation === 'sinewave' || this.animation === 'whirlpool') && this._msgSpan) { 329 this._msgSpan.textContent = newMessage; 330 } else { 331 this.updateDisplay(); 332 } 333 } 334 335 /** 336 * Update the spinner label text 337 */ 338 updateLabel(newMessage) { 339 this.message = newMessage; 340 if (this._msgSpan) { 341 this._msgSpan.textContent = newMessage; 342 } else { 343 this.updateDisplay(); 344 } 345 } 346 347 /** 348 * Destroy the spinner and clean up 349 */ 350 destroy() { 351 this.stop(); 352 if (this.element && this.element.parentNode) { 353 this.element.parentNode.removeChild(this.element); 354 } 355 this.element = null; 356 } 357} 358 359/** 360 * Create a new spinner instance 361 */ 362export function create(message, style = "right", animation = "wave") { 363 return new Spinner(message, style, animation); 364} 365 366/** 367 * Create a standalone whirlpool circle spinner (replaces CSS .spinner) 368 * Returns { element, start(), stop(), destroy() } 369 */ 370export function createWhirlpool(size = 24) { 371 const sp = new Spinner('', 'clean', 'whirlpool'); 372 sp._wpSize = size; 373 const el = sp.createElement(); 374 // wrap in a div matching .spinner layout 375 const wrap = document.createElement('div'); 376 wrap.className = 'spinner-whirlpool'; 377 wrap.style.cssText = `width:${size}px;height:${size}px;margin:8px auto;`; 378 wrap.appendChild(el); 379 sp.start(); 380 return { element: wrap, stop: () => sp.stop(), destroy: () => sp.destroy() }; 381} 382 383/** 384 * A consistent inline loading row for list/library empty-states: a label plus 385 * the whirlpool spinner. Returns a detached element; the spinner self-stops 386 * once the element leaves the DOM (see _drawWhirlpool), so callers can just 387 * replace it with results â no manual cleanup needed. 388 */ 389export function createLoadingRow(text = 'Loadingâ¦', size = 16) { 390 const sp = new Spinner('', 'clean', 'whirlpool'); 391 sp._wpSize = size; 392 const canvas = sp.createElement();
393 const row = document.createElement('div'); 394 row.className = 'lib-loading-row'; 395 const label = document.createElement('span'); 396 label.textContent = text; 397 row.appendChild(label); 398 row.appendChild(canvas); 399 sp.start(); 400 return row; 401} 402 403export { Spinner }; 404 405const spinnerModule = { create, createWhirlpool, createLoadingRow, Spinner }; 406export default spinnerModule;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.