File size: 6,185 Bytes
39371ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | 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();
},
};
}
|