diff --git a/css/popup.css b/css/popup.css index a6cdf2b..0fbb13b 100644 --- a/css/popup.css +++ b/css/popup.css @@ -94,6 +94,26 @@ h2 {margin-top: 20px;font-weight:600} .custom-tf-row input::-webkit-outer-spin-button, .custom-tf-row input::-webkit-inner-spin-button {opacity:1;} /* Collapsible "More options" section for the Better Todo List sub-options */ .todo-more-options {margin-top: 7px;} +/* Celebrations box — effect picker + amount for task completion animations */ +/* The big "Celebration" toggle is the whole box header; type/amount rows hide when it's off. */ +/* The two controls below mirror #todo_timeframe exactly (same font, padding, + radius, colors and full-width stacked layout) so they read like the popup's + other dropdowns. */ +.celebration-multiselect {position:relative;} +.celebration-multiselect-btn {display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;cursor:pointer;text-align:left;} +.celebration-multiselect-btn svg {flex:none;transition:transform .15s ease;} +.celebration-multiselect.open .celebration-multiselect-btn svg {transform:rotate(-180deg);} +.celebration-multiselect-menu {display:none;position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:60;background:#1e1e1e;border-radius:7px;padding:5px 0;max-height:180px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,.45);} +.celebration-multiselect.open .celebration-multiselect-menu {display:block;} +.celebration-multiselect.flip-up .celebration-multiselect-menu {top:auto;bottom:calc(100% + 4px);} +/* Menu rows mirror the native option colors (#1e1e1e idle, #2e2e2e hover) used by the other dropdowns */ +.celebration-option {display:flex;align-items:center;gap:7px;padding:5px 10px;font-size:12px;color:#e2e2e2;cursor:pointer;white-space:nowrap;user-select:none;} +.celebration-option:hover {background:#2e2e2e;color:#fff;} +.celebration-option input {accent-color:#56caf0;cursor:pointer;margin:0;} +#todo_celebration_amount {font-size:12px;background:var(--inputbg);color:#e2e2e2;border:none;border-radius:7px;padding:6px 8px;font-family:inherit;width:100%;box-sizing:border-box;margin-top:2px;margin-bottom:6px;color-scheme:dark;} +#todo_celebration_amount option {background:#1e1e1e;color:#e2e2e2;} +#todo_celebration_amount option:checked, +#todo_celebration_amount option:hover {background:#2e2e2e;color:#fff;} .todo-more-toggle {display: flex;align-items:center;gap:5px;font-size:12px;color:#a7a7a7;cursor:pointer;user-select:none;padding:3px 0;border-radius:5px;transition:color .1s;} .todo-more-toggle:hover {color:#e2e2e2;} .todo-more-toggle svg {flex:none;transition:transform .15s ease;} diff --git a/html/popup.html b/html/popup.html index 518024b..02b1377 100644 --- a/html/popup.html +++ b/html/popup.html @@ -314,10 +314,6 @@ -
- - -
@@ -365,6 +361,37 @@
+
+
+ + +
+
+
+
+ Celebration +
+
+ Celebration type +
+ +
+
+
+
+ + +
+ +
diff --git a/js/background.js b/js/background.js index 09c8a94..f039984 100644 --- a/js/background.js +++ b/js/background.js @@ -91,7 +91,9 @@ chrome.runtime.onInstalled.addListener(function () { "todo_timeframe": "all", "todo_timeframe_custom_past": 7, "todo_timeframe_custom_future": 14, - "todo_confetti": true, + "todo_celebrations_enabled": true, + "todo_celebrations": ["confetti"], + "todo_celebration_amount": "normal", "todo_show_scores": false, "custom_font_skip_p": false, "device_dark": false, diff --git a/js/content.js b/js/content.js index 1440053..246b16d 100644 --- a/js/content.js +++ b/js/content.js @@ -1956,6 +1956,7 @@ function recieveMessage(request, sender, sendResponse) { break; } case ("inspect"): sendResponse(inspectDarkMode(true)); break; + case ("previewCelebration"): sendResponse(runCelebrationPreview()); break; case ("fixdm"): sendResponse(runDarkModeFixer(true)); break; case ("updateBackground"): applyCustomBackground(); sendResponse(true); break; case ("clearPlannerCache"): @@ -4514,118 +4515,714 @@ function populateAnnouncements() { }); } +// Particle effects (confetti, fireworks, sparkles) render into ONE fullscreen +// per burst instead of individual DOM elements. Dozens of animated +// layers compositing over Canvas's blurred dashboard is itself the lag; a +// single canvas is one layer with cheap 2D draws, so even "crazy" volumes +// stay smooth. +function celebrationCanvas() { + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483647;'; + const dpr = Math.min(2, window.devicePixelRatio || 1); + canvas.width = Math.round(window.innerWidth * dpr); + canvas.height = Math.round(window.innerHeight * dpr); + document.body.appendChild(canvas); + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + return { canvas, ctx, w: window.innerWidth, h: window.innerHeight }; +} + +// Shared rAF driver: runs `step(now)` until it returns false, then removes +// the canvas (after a grace period so trailing fades finish). +function runCelebrationCanvas(view, step, graceMs) { + let rafId = null; + const loop = (now) => { + try { + if (step(now)) rafId = requestAnimationFrame(loop); + else { + setTimeout(() => { try { view.canvas.remove(); } catch (e) { /* ignore */ } }, graceMs || 100); + } + } catch (e) { + try { view.canvas.remove(); } catch (err) { /* ignore */ } + } + }; + rafId = requestAnimationFrame(loop); + // Hard stop so a backgrounded tab (rAF throttling) can't leak the canvas. + setTimeout(() => { try { rafId && cancelAnimationFrame(rafId); view.canvas.remove(); } catch (e) { /* ignore */ } }, 9000); +} + function createConfettiBurst(targetElement, opts = {}) { try { - if (options.todo_confetti === false) return; - - const count = opts.count || 48; + const amountScale = celebrationAmountScale(); + const count = Math.min(220, Math.round((opts.count || 48) * amountScale * (amountScale >= 4 ? 1.25 : amountScale >= 2 ? 1.2 : 1))); + const durationFactor = amountScale >= 4 ? 1.6 : amountScale >= 2 ? 1.35 : amountScale < 1 ? 0.85 : 1; + // Fan the burst out as the amount grows so bigger bursts don't read + // as one dense clump. + const spread = (opts.spread || 110) * (amountScale >= 4 ? 1.9 : amountScale >= 2 ? 1.45 : 1); const colors = opts.colors || ['#ff4d4f', '#ffc107', '#28a745', '#17a2b8', '#6f42c1', '#ff6b6b', '#ff8a65', '#ffd54f']; const rect = targetElement.getBoundingClientRect(); - const container = document.createElement('div'); - container.className = 'canvasrefined-confetti-container'; - container.style.position = 'fixed'; - container.style.left = '0'; - container.style.top = '0'; - container.style.pointerEvents = 'none'; - container.style.overflow = 'visible'; - container.style.zIndex = '2147483647'; - document.body.appendChild(container); + const view = celebrationCanvas(); + const ctx = view.ctx; const originX = rect.left + rect.width / 2; const originY = rect.top + rect.height * 0.35; - const particles = []; + const pieceScale = amountScale >= 4 ? 1.6 : amountScale >= 2 ? 1.35 : 1; + const pieces = []; + let maxEnd = 0; for (let i = 0; i < count; i++) { - const el = document.createElement('div'); - el.className = 'canvasrefined-confetti'; - const w = 4 + Math.floor(Math.random() * 7); // smaller pieces + const bigPiece = Math.random() < 0.25 && pieceScale > 1 ? 1.9 : 1; + const w = (4 + Math.floor(Math.random() * 7)) * pieceScale * bigPiece; const h = Math.max(3, Math.floor(w * (0.4 + Math.random() * 0.8))); - el.style.position = 'absolute'; - el.style.width = w + 'px'; - el.style.height = h + 'px'; - el.style.background = colors[Math.floor(Math.random() * colors.length)]; - el.style.left = (originX - w / 2) + 'px'; - el.style.top = (originY - h / 2) + 'px'; - el.style.opacity = '1'; - el.style.borderRadius = Math.random() > 0.75 ? '50%' : '2px'; - el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.18)'; - el.style.transformOrigin = 'center center'; - el.style.willChange = 'transform, opacity'; - container.appendChild(el); - - const duration = 850 + Math.floor(Math.random() * 500); - const delay = Math.floor(Math.random() * 90); - const spread = opts.spread || 110; + const duration = Math.round((850 + Math.random() * 500) * durationFactor); + const delay = Math.floor(Math.random() * 160); const horizontalBias = (Math.random() - 0.5) * 2; - - // Arc stays lower and wider than the old cone-shaped burst. const endX = originX + horizontalBias * (spread * (0.7 + Math.random() * 0.6)); const endY = originY - (16 + Math.random() * 34); const ctrlX = originX + horizontalBias * (spread * 0.25) + (Math.random() - 0.5) * 14; - const ctrlY = originY - (30 + Math.random() * 55); - - particles.push({ - el, + const ctrlY = originY - (30 + Math.random() * 55) * (amountScale >= 2 ? 1.4 : 1); + pieces.push({ delay, duration, - originX, - originY, - ctrlX, - ctrlY, - endX, - endY, - rotate: (Math.random() * 260) - 130, - scale: 0.8 + Math.random() * 0.5, + ox: originX - w / 2, + oy: originY - h / 2, + endX, endY, ctrlX, ctrlY, + w, h, + rot: (Math.random() * 260) - 130, + round: Math.random() > 0.75, + sc: 0.8 + Math.random() * 0.5, + color: colors[(Math.random() * colors.length) | 0], + }); + const end = delay + duration; + if (end > maxEnd) maxEnd = end; + } + + const startTime = performance.now(); + runCelebrationCanvas(view, (now) => { + ctx.clearRect(0, 0, view.w, view.h); + let active = false; + for (let i = 0; i < pieces.length; i++) { + const pc = pieces[i]; + const elapsed = now - startTime - pc.delay; + if (elapsed < 0) { active = true; continue; } + const p = Math.min(1, elapsed / pc.duration); + if (p < 1) active = true; + // Same arc as before: fast rise through the bezier peak, then + // settle into the end point while fading out. + const eased = 1 - Math.pow(1 - p, 3); + const inv = 1 - eased; + const x = inv * inv * pc.ox + 2 * inv * eased * pc.ctrlX + eased * eased * pc.endX; + const y = inv * inv * pc.oy + 2 * inv * eased * pc.ctrlY + eased * eased * pc.endY; + const sc = pc.sc * (1 - eased * 0.15); + ctx.save(); + ctx.globalAlpha = Math.max(0, 1 - p); + ctx.translate(x, y); + ctx.rotate((pc.rot * eased * Math.PI) / 180); + ctx.scale(sc, sc); + ctx.fillStyle = pc.color; + if (pc.round) { + ctx.beginPath(); + ctx.ellipse(0, 0, pc.w / 2, pc.h / 2, 0, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.fillRect(-pc.w / 2, -pc.h / 2, pc.w, pc.h); + } + ctx.restore(); + } + return active; + }, 150); + } catch (e) { + console.error('confetti error', e); + } +} + +// --- Completion celebrations ------------------------------------------------ +// Every effect renders into its own fixed-position container (pointer-events +// none, topmost z-index) so it ignores page scroll and layout. Overlay-particle +// effects keep the default task exit (slide right); "rocket" replaces the +// task's own exit animation by flying the element off the page, so +// runCelebration returns which exit the caller should apply. +const CELEBRATION_EFFECT_IDS = ["confetti", "fireworks", "sparkles", "hearts", "rocket"]; + +// Intensity tiers for the Celebrations "Amount" dropdown. "less" halves the +// particle counts; "more" doubles them and enlarges fireworks/hearts; "crazy" +// quadruples with even bigger visuals. Rocket uses the raw factor for its +// thruster size/length at every tier. +const CELEBRATION_AMOUNT_SCALES = { less: 0.5, normal: 1, more: 2, crazy: 4 }; +function celebrationAmountScale() { + const v = options.todo_celebration_amount; + return CELEBRATION_AMOUNT_SCALES[v] || 1; +} + +function enabledCelebrationTypes() { + // Master Celebrations toggle (default on). + if (options.todo_celebrations_enabled === false) return []; + // Legacy migration: before the Celebrations box there was a single + // "show confetti on complete" checkbox (todo_confetti). + const list = options.todo_celebrations; + if (Array.isArray(list)) return list.filter(t => CELEBRATION_EFFECT_IDS.includes(t)); + return options.todo_confetti === false ? [] : ["confetti"]; +} + +// Picks a random enabled effect and runs it against the task element. +// Returns the exit animation for the task element: +// "slide" (default slide-out), "wave", "rocket" or "rainbow". +// Small pause before the celebration fires and the exit animation starts, so +// the task is actually seen before everything happens (without it, overlay +// effects fired while the slide-out was already running, so the card seemed +// to never appear). +const CELEBRATION_LEAD_IN = 250; +// How long after the effect starts the task begins its exit. +const CELEBRATION_SLIDE_AFTER = 200; + +// Picks a random enabled effect and schedules it against the task element. +// Returns the task's exit as { style, delay, startDelay, start }: "slide" is +// the default slide-out, "rocket" flies the card off page. start/startDelay +// run the overlay effect; delay holds the exit back so the effect can play +// out first (hearts stay while they float). +function runCelebration(targetElement) { + const types = enabledCelebrationTypes(); + if (!types.length) return { style: "slide", delay: 0 }; + const type = types[Math.floor(Math.random() * types.length)]; + ensureCelebrationStyles(); + const lead = CELEBRATION_LEAD_IN; + switch (type) { + case "confetti": return { style: "slide", delay: lead + CELEBRATION_SLIDE_AFTER, startDelay: lead, start: () => createConfettiBurst(targetElement) }; + case "fireworks": return { style: "slide", delay: lead + 350, startDelay: lead, start: () => createFireworksBurst(targetElement) }; + case "sparkles": return { style: "slide", delay: lead + CELEBRATION_SLIDE_AFTER, startDelay: lead, start: () => createSparkleBurst(targetElement) }; + // Keep the task on screen a little longer so the hearts get going, + // but only about half their runtime — don't hold it hostage. + case "hearts": return { style: "slide", delay: lead + Math.round(risingHeartsRuntime() * 0.5), startDelay: lead, start: () => createRisingHearts(targetElement) }; + // The exhaust trail is spawned by applyTodoExitAnimation once the + // flying clone exists (it needs to track the clone's position), so + // the rocket has no separate start fn — its whole exit IS the effect. + case "rocket": return { style: "rocket", delay: lead, startDelay: lead, start: null }; + } + return { style: "slide", delay: 0 }; +} + +// Duration (ms) of each exit style's task-element animation. The todo list +// re-render waits at least this long so the element isn't ripped out of the +// DOM mid-animation ("slide" keeps the previous 400ms behavior). +const CELEBRATION_EXIT_DURATIONS = { slide: 400, rocket: 1250 }; +// Small headroom after the slide's transition ends before the list re-render +// swaps the DOM, so the swipe always plays out fully. +const CELEBRATION_EXIT_SETTLE = 60; +function celebrationExitDuration(exitStyle, delay) { + return (delay || 0) + (CELEBRATION_EXIT_DURATIONS[exitStyle] || CELEBRATION_EXIT_DURATIONS.slide) + CELEBRATION_EXIT_SETTLE; +} + +function applyTodoExitAnimation(element, exitStyle, delay) { + const run = () => { + if (element.__crCelebrationCancelled === true) return; + try { + if (exitStyle === "rocket") { + // The task launches off the page. The list container clips + // overflow, so animating the real element would cut it off + // halfway up — instead a fixed-position clone of the card is + // flown off screen while the original just disappears in place. + const rect = element.getBoundingClientRect(); + // Crazy rockets fly farther (and their exhaust travels farther to + // keep up with the higher speed). + const distF = celebrationAmountScale() >= 4 ? 1.9 : 1; + const clone = element.cloneNode(true); + clone.classList.remove("canvasrefined-rocket-exit"); + clone.style.setProperty("--cr-rx1", (2.5 * distF).toFixed(2) + "vw"); + clone.style.setProperty("--cr-ry1", Math.round(-55 * distF) + "vh"); + clone.style.setProperty("--cr-rx2", (8 * distF).toFixed(2) + "vw"); + clone.style.setProperty("--cr-ry2", Math.round(-125 * distF) + "vh"); + clone.style.position = "fixed"; + clone.style.left = rect.left + "px"; + clone.style.top = rect.top + "px"; + clone.style.width = rect.width + "px"; + clone.style.height = rect.height + "px"; + clone.style.margin = "0"; + clone.style.boxSizing = "border-box"; + clone.style.pointerEvents = "none"; + clone.style.zIndex = "2147483646"; + document.body.appendChild(clone); + element.__crRocketClone = clone; + element.style.transition = "none"; + element.style.opacity = "0"; + requestAnimationFrame(() => clone.classList.add("canvasrefined-rocket-exit")); + // Exhaust flames track the flying clone. + createRocketCelebration(clone); + setTimeout(() => { + try { clone.remove(); } catch (e) { /* ignore */ } + if (element.__crRocketClone === clone) element.__crRocketClone = null; + }, 1500); + } else { + // Slide-out (delay was already consumed by the wrapper above). + // The swipe relies on the row's inline "transition: all .4s ease"; + // if that's missing (e.g. cleared by an earlier revert on this + // element) the card would teleport instead of sliding, so make + // sure a transition exists before moving it. + if (getComputedStyle(element).transitionDuration === "0s") { + element.style.transition = "transform .4s ease, opacity .4s ease"; + } + // Double rAF so the slide always starts from the painted state. + requestAnimationFrame(() => requestAnimationFrame(() => { + element.style.transform = "translate(100%)"; + element.style.opacity = "0"; + })); + } + } catch (e) { + element.style.transform = "translate(100%)"; + element.style.opacity = "0"; + } + }; + if (delay > 0) { + if (element.__crExitTimer) clearTimeout(element.__crExitTimer); + element.__crExitTimer = setTimeout(() => { + element.__crExitTimer = null; + run(); + }, delay); + } else { + run(); + } +} + +let celebrationStylesEl = null; +function ensureCelebrationStyles() { + if (celebrationStylesEl && document.getElementById("canvasrefined-celebration-styles")) return; + celebrationStylesEl = document.createElement("style"); + celebrationStylesEl.id = "canvasrefined-celebration-styles"; + celebrationStylesEl.textContent = ` +@keyframes canvasrefined-rocket-exit { + 0% { transform: translate(0, 0) rotate(0deg); opacity: 1; } + 12% { transform: translate(0, 12px) rotate(0deg); opacity: 1; } + 60% { transform: translate(var(--cr-rx1, 2.5vw), var(--cr-ry1, -55vh)) rotate(4deg); opacity: 1; } + 100% { transform: translate(var(--cr-rx2, 8vw), var(--cr-ry2, -125vh)) rotate(8deg); opacity: 1; } +} +.canvasrefined-rocket-exit { animation: canvasrefined-rocket-exit 1.2s cubic-bezier(.55, 0, .85, .4) forwards !important; } +/* Rocket exhaust flames are animated with the Web Animations API + (el.animate): literal keyframe values, so Chrome always runs them on the + compositor. Confetti, firework and sparkle particles are canvas-rendered + instead (see celebrationCanvas). */ +.canvasrefined-rocket-flame { position: fixed; border-radius: 50%; pointer-events: none; z-index: 2147483647; } +/* Sparkle bursts are canvas-rendered now (see celebrationCanvas); no DOM + sparkle elements or keyframes are needed anymore. */ +/* Outer span rises + fades; inner span sways side to side on an eased + alternate loop, so the float reads as one smooth sinusoidal drift + (single-element keyframes made the sway/rotation visibly change + direction mid-flight). */ +@keyframes canvasrefined-heart-rise { + 0% { transform: translateY(6px) scale(.55); opacity: 0; animation-timing-function: cubic-bezier(.2, .7, .4, 1); } + 6% { transform: translateY(0) scale(var(--cr-s, 1)); opacity: 1; animation-timing-function: cubic-bezier(.3, .6, .5, 1); } + /* fast launch: a quarter of the climb happens in the first ~15% */ + 15% { transform: translateY(calc(var(--cr-hy, -160px) * .28)) scale(var(--cr-s, 1)); } + 80% { opacity: .92; } + 100% { transform: translateY(var(--cr-hy, -160px)) scale(var(--cr-s, 1)); opacity: 0; } +} +@keyframes canvasrefined-heart-sway { + 0% { transform: translateX(calc(var(--cr-hx, 14px) * -.5)) rotate(calc(var(--cr-rot, 9deg) * -1)); } + 100% { transform: translateX(calc(var(--cr-hx, 14px) * .5)) rotate(var(--cr-rot, 9deg)); } +} +.canvasrefined-heart { position: absolute; pointer-events: none; line-height: 1; will-change: transform, opacity; animation: canvasrefined-heart-rise var(--cr-dur, 1400ms) var(--cr-cdelay, 0ms) both; } +.canvasrefined-heart-inner { display: inline-block; animation: canvasrefined-heart-sway calc(var(--cr-dur, 1400ms) * .55) ease-in-out infinite alternate; } +`; + (document.head || document.documentElement).appendChild(celebrationStylesEl); +} + +function celebrationContainer() { + const container = document.createElement('div'); + container.style.position = 'fixed'; + container.style.left = '0'; + container.style.top = '0'; + container.style.pointerEvents = 'none'; + container.style.overflow = 'visible'; + container.style.zIndex = '2147483647'; + document.body.appendChild(container); + return container; +} + +function createFireworksBurst(targetElement) { + try { + const rect = targetElement.getBoundingClientRect(); + const view = celebrationCanvas(); + const ctx = view.ctx; + const colors = ['#ff4d4f', '#ffc107', '#4dd0e1', '#7c4dff', '#ff6ec7', '#ffd54f', '#69f0ae']; + const originX = rect.left + rect.width / 2; + const originY = rect.top + rect.height * 0.4; + const scale = celebrationAmountScale(); + // more/crazy launch more shells AND bigger bursts; less only trims. + const burstScale = scale > 1 ? (scale >= 4 ? 2.2 : 1.5) : 1; + const shellCount = Math.max(1, Math.round(3 * scale)); + const shellGap = 260 / Math.sqrt(scale); + const shells = []; + const sparks = []; + const flashes = []; + for (let i = 0; i < shellCount; i++) { + shells.push({ + start: i * shellGap + Math.random() * 130, + apexX: originX + (Math.random() - 0.5) * 90 * burstScale, + // Lower bursts overall, with per-shell height variation. + apexY: originY - (55 + Math.random() * 110) * burstScale, + duration: 320 + Math.random() * 160, + color: colors[(Math.random() * colors.length) | 0], + }); + } + + const explode = (shell, now) => { + flashes.push({ x: shell.apexX, y: shell.apexY, r: 26 * burstScale, born: now }); + const count = Math.min(48, Math.round((26 + Math.random() * 12) * scale)); + const life = 700 + Math.random() * 500; + for (let i = 0; i < count; i++) { + const angle = (Math.PI * 2 * i) / count + Math.random() * 0.35; + const speed = (60 + Math.random() * 100) * burstScale; + sparks.push({ + x: shell.apexX, + y: shell.apexY, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 25 * burstScale, + size: (2 + Math.random() * 3) * burstScale, + born: now, + duration: life, + color: shell.color, + }); + } + }; + + const startTime = performance.now(); + let last = startTime; + let remainingShells = shells.length; + runCelebrationCanvas(view, (now) => { + const dt = Math.min(50, now - last) / 1000; + last = now; + ctx.clearRect(0, 0, view.w, view.h); + let active = false; + + // shells climb from the task toward their apex + for (let i = shells.length - 1; i >= 0; i--) { + const sh = shells[i]; + const elapsed = now - startTime - sh.start; + if (elapsed < 0) { active = true; continue; } + const p = Math.min(1, elapsed / sh.duration); + const eased = 1 - Math.pow(1 - p, 2); + const x = originX + (sh.apexX - originX) * eased; + const y = originY + (sh.apexY - originY) * (1 - Math.pow(1 - eased, 1.6)); + ctx.beginPath(); + ctx.fillStyle = '#fffbe6'; + ctx.arc(x, y, 2.5, 0, Math.PI * 2); + ctx.fill(); + if (p >= 1) { explode(sh, now); shells.splice(i, 1); } + else active = true; + } + + // soft radial flashes at each burst point + for (let i = flashes.length - 1; i >= 0; i--) { + const f = flashes[i]; + const p = (now - f.born) / 300; + if (p >= 1) { flashes.splice(i, 1); continue; } + active = true; + const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r); + g.addColorStop(0, 'rgba(255,255,255,' + (0.85 * (1 - p)).toFixed(3) + ')'); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2); + ctx.fill(); + } + + // sparks: simple ballistic drift with gravity + drag + const gravity = 300; + for (let i = sparks.length - 1; i >= 0; i--) { + const sp = sparks[i]; + const p = (now - sp.born) / sp.duration; + if (p >= 1) { sparks.splice(i, 1); continue; } + active = true; + sp.x += sp.vx * dt; + sp.y += sp.vy * dt; + sp.vy += gravity * dt; + sp.vx *= (1 - 1.1 * dt); + ctx.globalAlpha = Math.max(0, 1 - p); + ctx.fillStyle = sp.color; + ctx.beginPath(); + ctx.arc(sp.x, sp.y, sp.size * (1 - p * 0.6), 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + + return active || shells.length > 0 || (remainingShells-- > 0 && now - startTime < shellCount * shellGap + 1500); + }, 200); + } catch (e) { + console.error('fireworks error', e); + } +} + +function createSparkleBurst(targetElement) { + try { + const rect = targetElement.getBoundingClientRect(); + const view = celebrationCanvas(); + const ctx = view.ctx; + const colors = ['#ffe082', '#fff59d', '#ffd54f', '#ffffff', '#ff8ab5', '#b388ff']; + const originX = rect.left + rect.width / 2; + const originY = rect.top + rect.height * 0.4; + const scale = celebrationAmountScale(); + const sparkCount = Math.min(80, Math.round(22 * scale)); + const sizeScale = scale >= 4 ? 1.5 : scale >= 2 ? 1.25 : 1; + const sparks = []; + for (let i = 0; i < sparkCount; i++) { + const angle = Math.random() * Math.PI * 2; + const dist = 40 + Math.random() * 95; + sparks.push({ + dx: Math.cos(angle) * dist, + dy: Math.sin(angle) * dist * 0.7 - 18, + size: (6 + Math.random() * 10) * sizeScale, + rot: Math.random() * 360, + dur: 700 + Math.random() * 500, + delay: Math.floor(Math.random() * 120), + color: colors[(Math.random() * colors.length) | 0], }); } const startTime = performance.now(); - let rafId = null; - - const animate = now => { + runCelebrationCanvas(view, (now) => { + ctx.clearRect(0, 0, view.w, view.h); let active = false; + for (let i = 0; i < sparks.length; i++) { + const sp = sparks[i]; + const elapsed = now - startTime - sp.delay; + if (elapsed < 0) { active = true; continue; } + const p = Math.min(1, elapsed / sp.dur); + if (p < 1) active = true; + // ease-out drift to the end point while scaling up then down + const eased = 1 - Math.pow(1 - p, 2.2); + const scalePulse = p < 0.3 ? 0.2 + (p / 0.3) * 0.8 : Math.max(0.1, 1 - ((p - 0.3) / 0.7) * 0.9); + const x = originX + sp.dx * eased; + const y = originY + sp.dy * eased; + const r = sp.size * scalePulse; + ctx.save(); + ctx.globalAlpha = Math.max(0, 1 - p * p); + ctx.translate(x, y); + ctx.rotate(((sp.rot + 220 * p) * Math.PI) / 180); + // 4-point star path + const q = r * 0.38; + ctx.fillStyle = sp.color; + ctx.beginPath(); + ctx.moveTo(0, -r); + ctx.quadraticCurveTo(q * 0.3, -q * 0.3, r, 0); + ctx.quadraticCurveTo(q * 0.3, q * 0.3, 0, r); + ctx.quadraticCurveTo(-q * 0.3, q * 0.3, -r, 0); + ctx.quadraticCurveTo(-q * 0.3, -q * 0.3, 0, -r); + ctx.fill(); + ctx.restore(); + } + return active; + }, 120); + } catch (e) { + console.error('sparkle error', e); + } +} - for (let i = particles.length - 1; i >= 0; i--) { - const particle = particles[i]; - const elapsed = now - startTime - particle.delay; - if (elapsed < 0) { - active = true; - continue; - } +// Total ms until the last heart finishes rising (used to hold the task's +// exit animation until the hearts have almost finished). Computed from the +// same formula the effect uses, so it's known before the effect starts. +function risingHeartsRuntime() { + const heartCount = Math.round(13 * celebrationAmountScale()); + return Math.min(2200, (heartCount - 1) * 20 + 1500) + 300; +} - const progress = Math.min(1, elapsed / particle.duration); - const eased = 1 - Math.pow(1 - progress, 3); +function createRisingHearts(targetElement) { + try { + const rect = targetElement.getBoundingClientRect(); + const container = celebrationContainer(); + const scale = celebrationAmountScale(); + const heartScale = scale >= 4 ? 2.2 : scale >= 2 ? 1.5 : 1; + const colors = ['#ff5c8a', '#ff80ab', '#f06292', '#ff4081', '#e91e63']; + const heartCount = Math.round(13 * scale); + for (let i = 0; i < heartCount; i++) { + const el = document.createElement('span'); + el.className = 'canvasrefined-heart'; + const inner = document.createElement('span'); + inner.className = 'canvasrefined-heart-inner'; + inner.textContent = '\u2764'; + el.appendChild(inner); + const size = (10 + Math.random() * 14) * heartScale; + el.style.fontSize = size + 'px'; + inner.style.color = colors[Math.floor(Math.random() * colors.length)]; + el.style.left = Math.round(rect.left + rect.width * 0.14 + Math.random() * rect.width * 0.72) + 'px'; + el.style.top = Math.round(rect.top + rect.height - 8) + 'px'; + inner.style.textShadow = '0 1px 4px rgba(0,0,0,.25)'; + el.style.setProperty('--cr-hy', '-' + Math.round(90 + Math.random() * 150) + 'px'); + el.style.setProperty('--cr-hx', (10 + Math.random() * 22).toFixed(1) + 'px'); + el.style.setProperty('--cr-s', (0.8 + Math.random() * 0.7).toFixed(2)); + el.style.setProperty('--cr-rot', Math.round(6 + Math.random() * 8) + 'deg'); + el.style.setProperty('--cr-dur', (900 + Math.random() * 600) + 'ms'); + // Lead the slide-away: hearts pop out instantly (fill-mode "both" + // keeps them hidden during their delay) and stream up in tight + // succession — the task is still on screen while they launch. + el.style.setProperty('--cr-cdelay', Math.round(i * (i < 3 ? 16 : 20)) + 'ms'); + container.appendChild(el); + } + // The container removal clears every heart; no per-heart timers needed. + // Scale the timeout with the count so big amounts aren't cut off. + setTimeout(() => { try { container.remove(); } catch (e) { /* ignore */ } }, 2400 + heartCount * 20); + return risingHeartsRuntime(); + } catch (e) { + console.error('hearts error', e); + } +} - const x = (1 - eased) * (1 - eased) * particle.originX + 2 * (1 - eased) * eased * particle.ctrlX + eased * eased * particle.endX; - const y = (1 - eased) * (1 - eased) * particle.originY + 2 * (1 - eased) * eased * particle.ctrlY + eased * eased * particle.endY; - - particle.el.style.transform = `translate(${Math.round(x - particle.originX)}px, ${Math.round(y - particle.originY)}px) rotate(${particle.rotate * eased}deg) scale(${particle.scale * (1 - eased * 0.15)})`; - particle.el.style.opacity = String(1 - progress); - - if (progress < 1) { - active = true; - } else { - particle.el.remove(); - particles.splice(i, 1); +// The rocket exit flies the task element itself off the page (via the +// canvasrefined-rocket-exit keyframes applied by applyTodoExitAnimation); +// this spawns the exhaust flame trail that follows the flying card. +function createRocketCelebration(targetElement) { + try { + const container = celebrationContainer(); + const colors = ['#ffb74d', '#ff9800', '#ff7043', '#ffd54f', '#eceff1', '#ffcc80']; + const flightMs = 1200; + const scale = celebrationAmountScale(); + const start = performance.now(); + const flames = []; // tracked so expiry is swept in the same pass + // Pool cap scales with the amount tier so "crazy" never saturates and + // starves the trail (which made the thruster visibly cut out early). + const poolCap = Math.round(60 * Math.max(1, scale)); + let rafId = null; + const tick = (now) => { + let r; + try { r = targetElement.getBoundingClientRect(); } catch (e) { r = null; } + // retire expired flames in the same pass (no per-flame timers) + for (let i = flames.length - 1; i >= 0; i--) { + if (now >= flames[i].expire) { + flames[i].el.remove(); + flames.splice(i, 1); } } - - if (active) { - rafId = requestAnimationFrame(animate); + const gone = !r || r.width === 0 || r.bottom < -110 || r.top > window.innerHeight + 110; + if (!gone && now - start < flightMs + 150) { + // Spawn every frame while the pool has room; flames live + // ~0.5-1s so the trail stays continuous until the card is + // fully offscreen. + if (flames.length < poolCap) { + const climb = Math.max(0.35, Math.min(1, (now - start) / 500)); // throttle ramps up after liftoff + // Capped: extra tier budget goes into flame size/length, + // not into an ever-growing per-frame spawn rate. + const jets = Math.min(8, Math.max(2, Math.round((4 + Math.random() * 2) * Math.sqrt(scale)))); + for (let i = 0; i < jets; i++) { + // Amount tier drives thruster particle size and flame length. + const size = (3 + Math.random() * 6) * scale; + const life = (380 + Math.random() * 380) * (scale >= 4 ? 1.3 : 1); + const el = document.createElement('div'); + el.className = 'canvasrefined-rocket-flame'; + el.style.width = (size * (0.8 + climb * 0.5)).toFixed(1) + 'px'; + el.style.height = (size * (1.4 + Math.random() * 1.2) * (0.8 + climb * 0.5)).toFixed(1) + 'px'; + el.style.left = Math.round(r.left + r.width * (0.38 + Math.random() * 0.24)) + 'px'; + el.style.top = Math.round(r.bottom - 4) + 'px'; + el.style.background = colors[(Math.random() * colors.length) | 0]; + // WAAPI: compositor-run motion; flame travel distance + // scales with the amount tier so the exhaust keeps up + // with bigger/faster rockets. + const fx = (Math.random() - 0.5) * 28 * scale; + const fy = 30 * scale; + el.animate([ + { transform: 'translate(0px, 0px) scale(1)', opacity: 0.95 }, + { transform: 'translate(' + fx.toFixed(1) + 'px, ' + fy + 'px) scale(0.15)', opacity: 0 } + ], { duration: Math.round(life), easing: 'ease-out', fill: 'forwards' }); + container.appendChild(el); + flames.push({ el, expire: now + life + 120 }); + } + } + rafId = requestAnimationFrame(tick); } else { - try { container.remove(); } catch (e) { /* ignore */ } - if (rafId) cancelAnimationFrame(rafId); + // Let the last flames finish, then drop the whole container. + setTimeout(() => { try { container.remove(); } catch (e) { /* ignore */ } }, 1100); } }; - - rafId = requestAnimationFrame(animate); - - // cleanup container after animations - setTimeout(() => { - try { container.remove(); } catch (e) { /* ignore */ } - }, 2400); + rafId = requestAnimationFrame(tick); } catch (e) { - console.error('confetti error', e); + console.error('rocket celebration error', e); + } +} + +// --- Celebration preview ----------------------------------------------------- +// "Preview" (popup Celebrations box): runs the currently selected effect + +// amount against a disposable fake task so the user can see what they'll get +// without completing a real task. The fake card drops into the real todo list +// when it's rendered here, or floats top-right as a fallback. +function runCelebrationPreview() { + try { + if (enabledCelebrationTypes().length === 0) return { ok: false, reason: "disabled" }; + + // Clean up any preview that's still playing. + const stale = document.getElementById("canvasrefined-celebration-preview"); + if (stale) stale.remove(); + + // Drop the fake task into the "Due Today" group so the celebration + // plays exactly where a real completion happens. The preview card + // reuses the real task-row markup (colored strip, title, due date) + // so its size/position matches an actual task. + const todayContainer = domContainers["0"]; + const listEl = document.getElementById("canvasrefined-todo-list"); + const onTasksTab = betterTodoFilter === "tasks" && todayContainer && todayContainer.wrapper.isConnected && listEl; + const card = document.createElement("div"); + card.id = "canvasrefined-celebration-preview"; + card.className = "better-todo-assignment"; + card.style.overflowX = "hidden"; + card.innerHTML = ` +
+
+
+ + + +
+
+
+
+ Celebration preview + Sample task + Due today +
+
+
`; + + let restoreWrapper = null; + if (onTasksTab) { + const groupList = todayContainer.wrapper.querySelector(".todo-group-list"); + if (groupList) groupList.insertBefore(card, groupList.firstChild); + // Force the Due Today group visible for the preview, but remember + // its previous state: when the group has no real items it starts + // hidden, and without restoring it the empty "Due Today" header + // would stay behind after the sample card leaves. + restoreWrapper = { + wrapper: todayContainer.wrapper, + display: todayContainer.wrapper.style.display, + hadVisible: todayContainer.wrapper.hasAttribute("data-has-visible"), + }; + todayContainer.wrapper.style.display = "block"; + todayContainer.wrapper.setAttribute("data-has-visible", "true"); + } else { + // Tasks tab not rendered here (other filter, other page) — float + // the sample card top-right so a preview is still possible. + card.style.cssText += "position:fixed;top:70px;right:16px;width:300px;z-index:2147483646;box-shadow:0 2px 10px rgba(0,0,0,.25);"; + document.body.appendChild(card); + } + + // Same flow as a real completion: pick a random enabled effect with + // the current amount, then apply the matching exit animation. The + // effect start honours the lead-in pause and is skipped if the sample + // card is gone by then (e.g. an immediate re-preview). + const celebration = runCelebration(card); + if (celebration.start) { + const startFn = celebration.start; + setTimeout(() => { if (card.isConnected) startFn(); }, celebration.startDelay || 0); + } + applyTodoExitAnimation(card, celebration.style, celebration.delay); + setTimeout(() => { + try { card.remove(); } catch (e) { /* ignore */ } + // Put the Due Today group back the way we found it. + if (restoreWrapper) { + try { + restoreWrapper.wrapper.style.display = restoreWrapper.display; + if (!restoreWrapper.hadVisible) restoreWrapper.wrapper.removeAttribute("data-has-visible"); + } catch (e) { /* wrapper re-rendered away already */ } + } + }, celebrationExitDuration(celebration.style, celebration.delay) + 120); + return { ok: true }; + } catch (e) { + console.error("celebration preview error", e); + return { ok: false, reason: "error" }; } } @@ -4678,18 +5275,50 @@ function markAs(item, element, makeComplete) { item.planner_override = item.planner_override || {}; item.planner_override.marked_complete = completeState; - // fire confetti only when marking complete (not when unmarking). - // Must happen BEFORE the slide-out transform below: the burst measures - // the element with getBoundingClientRect(), which reflects transforms — + // A previous markAs on this same element may still have celebration or + // exit timers pending (fast double-click). Cancel them so the new action + // starts clean instead of racing the old one's scheduled effect/exit. + element.__crCelebrationCancelled = true; + if (element.__crExitTimer) { clearTimeout(element.__crExitTimer); element.__crExitTimer = null; } + if (element.__crCelebTimer) { clearTimeout(element.__crCelebTimer); element.__crCelebTimer = null; } + element.__crCelebrationCancelled = false; + + // Celebrate only when marking complete (not when unmarking). + // Must happen BEFORE the exit animation below: the effects measure the + // element with getBoundingClientRect(), which reflects transforms — // measuring after translate(100%) would place the origin one full card // width to the right (off screen) whenever the transform applies // instantly, i.e. on cards without a transition (announcements). + let exitStyle = "slide"; + let exitDelay = 0; if (completeState) { - try { createConfettiBurst(element); } catch (e) { console.error('confetti trigger error', e); } + try { + const celebration = runCelebration(element); + exitStyle = celebration.style; + exitDelay = celebration.delay || 0; + // Overlay effects (confetti/fireworks/sparkles/hearts) fire after + // the lead-in pause; the exit is scheduled separately. Both are + // cancelled if the optimistic UI gets reverted meanwhile. + if (celebration.start) { + const startFn = celebration.start; + const fire = () => { + if (element.__crCelebrationCancelled === true || !element.isConnected) return; + startFn(); + }; + if (celebration.startDelay > 0) { + if (element.__crCelebTimer) clearTimeout(element.__crCelebTimer); + element.__crCelebTimer = setTimeout(() => { + element.__crCelebTimer = null; + fire(); + }, celebration.startDelay); + } else { + fire(); + } + } + } catch (e) { console.error('celebration trigger error', e); } } - element.style.transform = "translate(100%)"; - element.style.opacity = "0"; + applyTodoExitAnimation(element, exitStyle, exitDelay); // update progress rings immediately so they animate while the item slides/fades const refreshProgressRings = () => { @@ -4737,9 +5366,26 @@ function markAs(item, element, makeComplete) { setPinnedIncomplete(item, false); } console.warn("planner override not persisted, reverting UI:", reason); - // snap the card back if it is still in the DOM (before the 400ms swap) + // snap the card back if it is still in the DOM (before the swap); + // also undo any celebration exit animation applied to it element.style.transform = ""; element.style.opacity = ""; + element.style.transition = ""; + element.style.visibility = ""; + element.classList.remove("canvasrefined-rocket-exit"); + element.__crCelebrationCancelled = true; + if (element.__crExitTimer) { + clearTimeout(element.__crExitTimer); + element.__crExitTimer = null; + } + if (element.__crCelebTimer) { + clearTimeout(element.__crCelebTimer); + element.__crCelebTimer = null; + } + if (element.__crRocketClone) { + try { element.__crRocketClone.remove(); } catch (e) { /* ignore */ } + element.__crRocketClone = null; + } const loc = document.querySelector("#canvasrefined-todo-list"); if (loc) { clearTodoList(); createTodoSections(loc); } refreshProgressRings(); @@ -4748,7 +5394,7 @@ function markAs(item, element, makeComplete) { setTimeout(() => { clearTodoList(); createTodoSections(document.querySelector("#canvasrefined-todo-list")); - }, 400); + }, celebrationExitDuration(exitStyle, exitDelay)); // --- Persistence (background, best-effort) --- // Toggle the planner override server-side. The override id returned by a diff --git a/js/popup.js b/js/popup.js index 8fce63b..792e47b 100644 --- a/js/popup.js +++ b/js/popup.js @@ -1,12 +1,13 @@ -const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details']; +const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'todo_celebrations_enabled', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'hide_sequence_footer', 'center_cards', 'quiz_safe_mode', 'global_search', 'grade_analytics', 'hide_personal_details']; const syncedSubOptions = [ "grade_analytics_zones", "hide_course_images", "todo_hide_feedback", "todo_hide_read", "todo_full_height", - "todo_confetti", - "todo_progress_rings", + "todo_celebrations", + "todo_celebration_amount", + "todo_progress_rings", "todo_timeframe", "todo_timeframe_custom_past", "todo_timeframe_custom_future", @@ -76,7 +77,7 @@ const exportCardColorToggles = ["gradient_cards", "disable_color_overlay"]; const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding", "card_grid", "card_grid_columns", "card_grid_rows", "card_grid_column_gap", "card_grid_row_gap", "card_grid_center_rows", "card_grid_flex"]; const exportLayout = ["full_width", "center_cards", "condensed_cards", "equal_height_cards", "remlogo", "hide_new_canvas", "hide_navbar", "tab_icons"]; const exportSidebar = ["better_sidebar", "sidebar_scale"]; -const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_confetti", "todo_progress_rings", "todo_timeframe", "todo_timeframe_custom_past", "todo_timeframe_custom_future", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"]; +const exportTodo = ["better_todo", "todo_hide_feedback", "todo_hide_read", "todo_full_height", "todo_celebrations_enabled", "todo_celebrations", "todo_celebration_amount", "todo_progress_rings", "todo_timeframe", "todo_timeframe_custom_past", "todo_timeframe_custom_future", "todo_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"]; const exportGpa = ["gpa_calc", "gpa_calc_prepend", "gpa_calc_cumulative", "gpa_calc_weighted"]; const exportBackground = ["customBackgroundLink", "customBackgroundScale", "customBackgroundDaily", "customBackgroundNasaDaily", "fitImageToScreen", "card_transparency", "bg_opacity", "sidebar_opacity", "bg_blur", "sidebar_blur", "card_opacity", "card_blur"]; // Master "On/off toggles" = every visual toggle (no GPA, no dark-mode schedule, @@ -202,7 +203,9 @@ const defaultOptions = { "todo_timeframe_custom_past": 7, "todo_timeframe_custom_future": 14, "todo_more_expanded": false, - "todo_confetti": true, + "todo_celebrations_enabled": true, + "todo_celebrations": ["confetti"], + "todo_celebration_amount": "normal", "device_dark": false, "cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 }, // "show_updates": false, @@ -389,6 +392,128 @@ function setupProgressRingsSelect(initial) { }); } +// Celebration effects for task completion. `todo_celebrations` holds the +// selected effect ids (multi-select); a random one runs each time a task is +// marked complete. `todo_celebration_amount` scales intensity ("normal" only +// for now, kept as a string so more tiers can be added later). +const CELEBRATION_TYPES = [ + { id: "confetti", label: "Confetti" }, + { id: "fireworks", label: "Fireworks" }, + { id: "sparkles", label: "Sparkle burst" }, + { id: "hearts", label: "Rising hearts" }, + { id: "rocket", label: "Rocket" }, +]; + +// Resolves the stored celebrations value to a list of valid effect ids. +// Handles the legacy single "show confetti on complete" checkbox +// (todo_confetti false meant "off") and drops unknown/legacy ids. +function resolveCelebrations(raw, legacyConfetti) { + if (Array.isArray(raw)) { + return [...new Set(raw.filter(id => CELEBRATION_TYPES.some(t => t.id === id)))]; + } + return legacyConfetti === false ? [] : ["confetti"]; +} + +function setupCelebrationsSelect(initial) { + const wrap = document.getElementById("todo_celebrations"); + const btn = document.getElementById("todo_celebrations_btn"); + const menu = document.getElementById("todo_celebrations_menu"); + const label = document.getElementById("todo_celebrations_label"); + if (!wrap || !btn || !menu || !label) return; + + let selected = []; + const renderMenu = () => { + menu.innerHTML = CELEBRATION_TYPES.map(t => ` + + `).join(""); + menu.querySelectorAll("input").forEach(input => { + input.addEventListener("change", () => { + if (input.checked) { + if (!selected.includes(input.value)) selected.push(input.value); + } else { + selected = selected.filter(id => id !== input.value); + } + updateLabel(); + chrome.storage.sync.set({ "todo_celebrations": selected }); + }); + }); + }; + const updateLabel = () => { + if (selected.length === 0) label.textContent = "None"; + else if (selected.length === 1) label.textContent = CELEBRATION_TYPES.find(t => t.id === selected[0])?.label || "None"; + else label.textContent = `${selected.length} selected`; + }; + + // Read the legacy key so users upgrading from the old "show confetti" + // toggle keep their setting (the special handler only passes the new key). + chrome.storage.sync.get("todo_confetti", legacy => { + selected = resolveCelebrations(initial, legacy && legacy["todo_confetti"]); + renderMenu(); + updateLabel(); + }); + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const open = wrap.classList.toggle("open"); + btn.setAttribute("aria-expanded", open ? "true" : "false"); + // The box sits low in the popup, so the menu often has more room + // above it — flip upward whenever opening downward would overflow. + if (open) { + const mr = menu.getBoundingClientRect(); + if (mr.bottom > window.innerHeight - 8) wrap.classList.add("flip-up"); + else wrap.classList.remove("flip-up"); + } + }); + menu.addEventListener("click", (e) => e.stopPropagation()); + document.addEventListener("click", (e) => { + if (!wrap.contains(e.target)) { + wrap.classList.remove("open"); + btn.setAttribute("aria-expanded", "false"); + } + }); +} + +function setupCelebrationAmount(initial) { + const el = document.getElementById("todo_celebration_amount"); + if (!el) return; + const allowed = ["less", "normal", "more", "crazy"]; + el.value = allowed.includes(initial) ? initial : "normal"; + el.addEventListener("change", function () { + chrome.storage.sync.set({ "todo_celebration_amount": this.value }); + }); +} + +// "Preview celebration": asks Canvas tabs to run the current effect + amount +// against a disposable fake task. Only enabled rows respond; if none respond +// (no Canvas tab open) tell the user instead of failing silently. +function setupCelebrationPreview() { + const btn = document.getElementById("celebration-preview-btn"); + if (!btn) return; + btn.addEventListener("click", async () => { + const res = await sendFromPopup("previewCelebration"); + if (!res || res.ok !== true) { + displayAlert(true, "Open a Canvas page to preview celebrations."); + } + }); +} + +// The Celebrations master toggle hides the type/amount rows when off. +function toggleCelebrationOptionsVisibility(enabled) { + document.querySelectorAll("#celebrations-container .celebration-sub").forEach(row => { + row.style.display = enabled ? "" : "none"; + }); +} + +// The Celebrations box only makes sense with the Better Todo List, since +// celebrations fire when its tasks are marked complete. +function toggleCelebrationsVisibility(betterTodoOn) { + const el = document.getElementById("celebrations-container"); + if (el) el.style.display = betterTodoOn ? "" : "none"; +} + // Collapsible "More options" section for the Better Todo List sub-options. // Persists whether it's expanded so the popup reopens in the same state. function setupTodoMoreOptions(initial) { @@ -1246,7 +1371,6 @@ function setup() { // /*'card_method_date',*/ "show_updates", "todo_hide_feedback", "todo_hide_read", - "todo_confetti", "todo_full_height", "device_dark", "relative_dues", @@ -1425,6 +1549,18 @@ function setup() { identifier: "customBackgroundScale", setup: (initial) => setupCustomBackgroundScale(initial), }, + { + identifier: "todo_celebrations", + setup: (initial) => setupCelebrationsSelect(initial), + }, + { + identifier: "todo_celebration_amount", + setup: (initial) => setupCelebrationAmount(initial), + }, + { + identifier: "celebration_preview", + setup: () => setupCelebrationPreview(), + }, { identifier: "todo_progress_rings", setup: (initial) => setupProgressRingsSelect(initial), @@ -1467,7 +1603,7 @@ function setup() { chrome.storage.sync.get(menu.switches, sync => { menu.switches.forEach(option => { let optionSwitch = document.getElementById(option); - let status = sync[option] === true ? "#on" : "#off"; + let status = (sync[option] !== undefined ? sync[option] === true : defaultOptions.sync[option] === true) ? "#on" : "#off"; optionSwitch.querySelector(status).checked = true; optionSwitch.querySelector(status).classList.add('checked'); @@ -1483,6 +1619,12 @@ function setup() { if (option === "better_sidebar") { toggleBetterSidebarSubOptions(status); } + if (option === "better_todo") { + toggleCelebrationsVisibility(status); + } + if (option === "todo_celebrations_enabled") { + toggleCelebrationOptionsVisibility(status); + } if (option === "dark_mode") { toggleAlternateColorsVisibility(status); } @@ -1499,6 +1641,10 @@ function setup() { }); }); toggleBetterSidebarSubOptions(sync["better_sidebar"] === true); + toggleCelebrationsVisibility(sync["better_todo"] === true); + chrome.storage.sync.get("todo_celebrations_enabled", v => { + toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false); + }); ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => { toggleSubOptionsVisibility(opt, sync[opt] === true); }); @@ -2324,7 +2470,9 @@ function saveCurrentTheme() { "todo_hide_feedback": current["todo_hide_feedback"], "todo_hide_read": current["todo_hide_read"] !== false, "todo_full_height": current["todo_full_height"], - "todo_confetti": current["todo_confetti"], + "todo_celebrations_enabled": current["todo_celebrations_enabled"], + "todo_celebrations": current["todo_celebrations"], + "todo_celebration_amount": current["todo_celebration_amount"], "todo_progress_rings": current["todo_progress_rings"], "todo_timeframe": current["todo_timeframe"], "todo_timeframe_custom_past": current["todo_timeframe_custom_past"], @@ -2509,6 +2657,11 @@ function importTheme(theme) { } break; } + case "todo_confetti": + // Legacy setting: map the old confetti toggle onto the + // celebrations list ("false" meant celebrations off). + final["todo_celebrations"] = theme["todo_confetti"] === false ? [] : ["confetti"]; + break; default: final[key] = theme[key]; break;