Download app/src/pi/empty-mascot.mjs from Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP: direct link, hf CLI and curl.
- Browser
- Download file 6.19 kB
-
https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/app/src/pi/empty-mascot.mjs
- Command line
-
hf download hf://spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/app/src/pi/empty-mascot.mjs
-
curl -L -o empty-mascot.mjs https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/app/src/pi/empty-mascot.mjs
6.19 kB
| import { playCatSound } from '../cat-sound.mjs'; | |
| export function createEmptyMascot(element, onSelect) { | |
| const measure = document.createElement('canvas').getContext('2d'); | |
| const baselineMarker = document.createElement('span'); | |
| baselineMarker.style.cssText = 'display:inline-block;width:0;height:0;padding:0;border:0;vertical-align:baseline'; | |
| const mascot = document.createElement('div'); | |
| mascot.className = 'empty-mascot'; mascot.hidden = true; | |
| // Measure its final position while keeping the bubble and cat hidden together. | |
| mascot.style.visibility = 'hidden'; | |
| const suggestion = document.createElement('button'); | |
| suggestion.type = 'button'; suggestion.className = 'mascot-suggestion'; | |
| const figure = document.createElement('div'); | |
| figure.className = 'mascot-cat'; figure.setAttribute('aria-hidden', 'true'); | |
| const cat = document.createElement('img'); | |
| cat.width = cat.height = 104; cat.alt = ''; cat.draggable = false; | |
| const eyelids = document.createElement('span'); | |
| eyelids.className = 'mascot-eyelids'; eyelids.setAttribute('aria-hidden', 'true'); | |
| figure.append(cat, eyelids); | |
| mascot.append(suggestion, figure); element.append(mascot); | |
| let prompt = '', imageReady = false; | |
| let bumped = false, bumpTimer = 0, blinkTimer = 0; | |
| const animations = new Set(); | |
| const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); | |
| function animate(target, frames, options) { | |
| for (const active of animations) if (active.id === options.id) { active.cancel(); animations.delete(active); } | |
| const animation = target.animate(frames, options); | |
| animations.add(animation); | |
| animation.onfinish = () => animations.delete(animation); | |
| } | |
| function hop() { | |
| clearTimeout(bumpTimer); bumpTimer = 0; | |
| if (mascot.hidden || document.hidden || reducedMotion.matches || !imageReady) return; | |
| bumped = true; | |
| animate(figure, [{ transform: 'translate3d(0, 0, 0)', easing: 'ease-out' }, { transform: 'translate3d(0, -4px, 0)', offset: .4, easing: 'ease-in' }, { transform: 'translate3d(0, 0, 0)' }], | |
| { id: 'mascot-bump', duration: 420 }); | |
| } | |
| figure.onclick = () => { playCatSound(); hop(); }; | |
| figure.onpointerdown = event => event.preventDefault(); | |
| figure.onmousedown = event => event.preventDefault(); | |
| function stopMotion() { | |
| clearTimeout(bumpTimer); clearTimeout(blinkTimer); | |
| bumpTimer = blinkTimer = 0; | |
| for (const animation of animations) animation.cancel(); | |
| animations.clear(); | |
| } | |
| function syncMotion() { | |
| if (mascot.hidden || document.hidden || reducedMotion.matches || !imageReady) { stopMotion(); return; } | |
| if (!bumped && !bumpTimer) bumpTimer = setTimeout(hop, 1800); | |
| if (!blinkTimer) blinkTimer = setTimeout(() => { | |
| blinkTimer = 0; | |
| animate(eyelids, [{ opacity: 0, easing: 'steps(1, end)' }, { opacity: 1, offset: .15 }, { opacity: 1, offset: .8, easing: 'steps(1, end)' }, { opacity: 0 }], | |
| { id: 'mascot-blink', duration: 180 }); | |
| syncMotion(); | |
| }, 5000 + Math.random() * 4000); | |
| } | |
| function hide() { mascot.hidden = true; stopMotion(); } | |
| async function revealImage() { | |
| try { await cat.decode(); } catch { return; } | |
| if (!mascot.isConnected) return; | |
| imageReady = true; | |
| mascot.style.visibility = ''; | |
| syncMotion(); | |
| } | |
| cat.addEventListener('load', revealImage); | |
| document.addEventListener('visibilitychange', syncMotion); | |
| reducedMotion.addEventListener('change', syncMotion); | |
| suggestion.onclick = () => onSelect(prompt); | |
| return { | |
| hide, | |
| get statusInset() { return mascot.hidden ? 0 : figure.offsetWidth + 20; }, | |
| update({ screen, rows, viewportRows, contentRows, draft, hasSales, hasProducts }) { | |
| if (!screen || !rows) { hide(); return; } | |
| const area = screen.getBoundingClientRect(), root = element.getBoundingClientRect(); | |
| const rowHeight = area.height / rows; | |
| const room = (viewportRows - contentRows) * rowHeight - 16; | |
| if (room < 48) { hide(); return; } | |
| if (!cat.hasAttribute('src')) cat.src = new URL('../../public/empty-cat-v1.png', import.meta.url).href; | |
| const withSuggestion = !draft.trim() && room >= 180; | |
| suggestion.hidden = !withSuggestion; | |
| suggestion.textContent = hasSales && hasProducts | |
| ? 'Ask me to calculate our total sales.' | |
| : 'Ask me to explore your files.'; | |
| prompt = hasSales && hasProducts | |
| ? "What's our total sales revenue?" | |
| : 'Explore the workspace files and summarize what is here.'; | |
| suggestion.setAttribute('aria-label', `Use suggested prompt: ${prompt}`); | |
| const size = Math.min(104, Math.floor(withSuggestion ? room - 76 : room)); | |
| figure.style.width = figure.style.height = `${size}px`; | |
| mascot.hidden = false; | |
| // One status row precedes the editor's top border. Measure the actual | |
| // box-drawing glyph so its baseline follows the browser's font and scale. | |
| let ground = area.top + (viewportRows + 1.5) * rowHeight; | |
| const border = screen.querySelector('.xterm-rows')?.children[viewportRows + 1]?.firstElementChild; | |
| const scale = devicePixelRatio || 1; | |
| if (border && measure) { | |
| const style = getComputedStyle(border); | |
| border.parentElement.append(baselineMarker); | |
| const baseline = baselineMarker.getBoundingClientRect().top; | |
| baselineMarker.remove(); | |
| measure.font = `${style.fontWeight} ${parseFloat(style.fontSize) * scale}px ${style.fontFamily}`; | |
| const ink = measure.measureText('─'); | |
| ground = baseline - (ink.actualBoundingBoxAscent - ink.actualBoundingBoxDescent) / 2 / scale; | |
| } | |
| ground = Math.floor(ground * scale + .0001) / scale; | |
| // The paws end at row 1156 in the 1250px sprite; exclude its black margin. | |
| const pawHeight = Math.round(size * 1156 / 1250 * scale) / scale; | |
| mascot.style.top = `${ground - root.top + size - pawHeight - mascot.getBoundingClientRect().height}px`; | |
| syncMotion(); | |
| }, | |
| dispose() { | |
| hide(); | |
| cat.removeEventListener('load', revealImage); | |
| document.removeEventListener('visibilitychange', syncMotion); | |
| reducedMotion.removeEventListener('change', syncMotion); | |
| mascot.remove(); | |
| }, | |
| }; | |
| } | |