new effects

This commit is contained in:
Guy Sandler 2026-10-12 21:27:32 -07:00
parent d6e5deceac
commit 6aa05b268d
5 changed files with 951 additions and 103 deletions

View File

@ -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;} .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 */ /* Collapsible "More options" section for the Better Todo List sub-options */
.todo-more-options {margin-top: 7px;} .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 {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:hover {color:#e2e2e2;}
.todo-more-toggle svg {flex:none;transition:transform .15s ease;} .todo-more-toggle svg {flex:none;transition:transform .15s ease;}

View File

@ -314,10 +314,6 @@
<input type="checkbox" id="todo_show_scores" name="todo_show_scores"> <input type="checkbox" id="todo_show_scores" name="todo_show_scores">
<label for="todo_show_scores" class="sub-text">Show assignment scores</label> <label for="todo_show_scores" class="sub-text">Show assignment scores</label>
</div> </div>
<div class="sub-option">
<input type="checkbox" id="todo_confetti" name="todo_confetti">
<label for="todo_confetti" class="sub-text">Show confetti on complete</label>
</div>
<div class="sub-option"> <div class="sub-option">
<input type="checkbox" id="todo_hide_read" name="todo_hide_read"> <input type="checkbox" id="todo_hide_read" name="todo_hide_read">
<label for="todo_hide_read" class="sub-text">Hide read announcements</label> <label for="todo_hide_read" class="sub-text">Hide read announcements</label>
@ -365,6 +361,37 @@
</div> </div>
</div> </div>
</div> </div>
<div class="option-container" id="celebrations-container">
<div class="option" id="todo_celebrations_enabled" style="margin-top:0">
<input type="radio" id="off" name="todo_celebrations_enabled">
<input type="radio" id="on" name="todo_celebrations_enabled">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div>
<span class="option-name">Celebration</span>
</div>
<div class="sub-option celebration-sub" id="celebration-type-row" style="margin-top:6px;flex-direction:column;align-items:stretch;gap:4px;">
<span class="sub-text">Celebration type</span>
<div class="celebration-multiselect" id="todo_celebrations">
<button type="button" class="celebration-multiselect-btn" id="todo_celebrations_btn" aria-haspopup="listbox" aria-expanded="false">
<span id="todo_celebrations_label">Confetti</span>
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 9l6 6 6-6" /></svg>
</button>
<div class="celebration-multiselect-menu" id="todo_celebrations_menu" role="listbox" aria-multiselectable="true"></div>
</div>
</div>
<div class="sub-option celebration-sub" id="celebration-amount-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<label for="todo_celebration_amount" class="sub-text">Amount</label>
<select id="todo_celebration_amount" name="todo_celebration_amount">
<option value="less">Less</option>
<option value="normal">Normal</option>
<option value="more">More</option>
<option value="crazy">Crazy</option>
</select>
</div>
<button type="button" id="celebration-preview-btn" class="customization-button celebration-sub" style="margin-top:8px;width:100%;">Preview celebration</button>
</div>
<div class="option-container"> <div class="option-container">
<div class="option" id="better_sidebar"> <div class="option" id="better_sidebar">
<input type="radio" id="off" name="better_sidebar"> <input type="radio" id="off" name="better_sidebar">

View File

@ -91,7 +91,9 @@ chrome.runtime.onInstalled.addListener(function () {
"todo_timeframe": "all", "todo_timeframe": "all",
"todo_timeframe_custom_past": 7, "todo_timeframe_custom_past": 7,
"todo_timeframe_custom_future": 14, "todo_timeframe_custom_future": 14,
"todo_confetti": true, "todo_celebrations_enabled": true,
"todo_celebrations": ["confetti"],
"todo_celebration_amount": "normal",
"todo_show_scores": false, "todo_show_scores": false,
"custom_font_skip_p": false, "custom_font_skip_p": false,
"device_dark": false, "device_dark": false,

View File

@ -1956,6 +1956,7 @@ function recieveMessage(request, sender, sendResponse) {
break; break;
} }
case ("inspect"): sendResponse(inspectDarkMode(true)); break; case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("previewCelebration"): sendResponse(runCelebrationPreview()); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break; case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break; case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
case ("clearPlannerCache"): case ("clearPlannerCache"):
@ -4514,15 +4515,317 @@ function populateAnnouncements() {
}); });
} }
// Particle effects (confetti, fireworks, sparkles) render into ONE fullscreen
// <canvas> 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 = {}) { function createConfettiBurst(targetElement, opts = {}) {
try { try {
if (options.todo_confetti === false) return; const amountScale = celebrationAmountScale();
const count = Math.min(220, Math.round((opts.count || 48) * amountScale * (amountScale >= 4 ? 1.25 : amountScale >= 2 ? 1.2 : 1)));
const count = opts.count || 48; 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 colors = opts.colors || ['#ff4d4f', '#ffc107', '#28a745', '#17a2b8', '#6f42c1', '#ff6b6b', '#ff8a65', '#ffd54f'];
const rect = targetElement.getBoundingClientRect(); const rect = targetElement.getBoundingClientRect();
const view = celebrationCanvas();
const ctx = view.ctx;
const originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height * 0.35;
const pieceScale = amountScale >= 4 ? 1.6 : amountScale >= 2 ? 1.35 : 1;
const pieces = [];
let maxEnd = 0;
for (let i = 0; i < count; i++) {
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)));
const duration = Math.round((850 + Math.random() * 500) * durationFactor);
const delay = Math.floor(Math.random() * 160);
const horizontalBias = (Math.random() - 0.5) * 2;
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) * (amountScale >= 2 ? 1.4 : 1);
pieces.push({
delay,
duration,
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'); const container = document.createElement('div');
container.className = 'canvasrefined-confetti-container';
container.style.position = 'fixed'; container.style.position = 'fixed';
container.style.left = '0'; container.style.left = '0';
container.style.top = '0'; container.style.top = '0';
@ -4530,102 +4833,396 @@ function createConfettiBurst(targetElement, opts = {}) {
container.style.overflow = 'visible'; container.style.overflow = 'visible';
container.style.zIndex = '2147483647'; container.style.zIndex = '2147483647';
document.body.appendChild(container); 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 originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height * 0.35; const originY = rect.top + rect.height * 0.4;
const particles = []; 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++) { for (let i = 0; i < count; i++) {
const el = document.createElement('div'); const angle = (Math.PI * 2 * i) / count + Math.random() * 0.35;
el.className = 'canvasrefined-confetti'; const speed = (60 + Math.random() * 100) * burstScale;
const w = 4 + Math.floor(Math.random() * 7); // smaller pieces sparks.push({
const h = Math.max(3, Math.floor(w * (0.4 + Math.random() * 0.8))); x: shell.apexX,
el.style.position = 'absolute'; y: shell.apexY,
el.style.width = w + 'px'; vx: Math.cos(angle) * speed,
el.style.height = h + 'px'; vy: Math.sin(angle) * speed - 25 * burstScale,
el.style.background = colors[Math.floor(Math.random() * colors.length)]; size: (2 + Math.random() * 3) * burstScale,
el.style.left = (originX - w / 2) + 'px'; born: now,
el.style.top = (originY - h / 2) + 'px'; duration: life,
el.style.opacity = '1'; color: shell.color,
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 startTime = performance.now();
const delay = Math.floor(Math.random() * 90); let last = startTime;
const spread = opts.spread || 110; let remainingShells = shells.length;
const horizontalBias = (Math.random() - 0.5) * 2; runCelebrationCanvas(view, (now) => {
const dt = Math.min(50, now - last) / 1000;
last = now;
ctx.clearRect(0, 0, view.w, view.h);
let active = false;
// Arc stays lower and wider than the old cone-shaped burst. // shells climb from the task toward their apex
const endX = originX + horizontalBias * (spread * (0.7 + Math.random() * 0.6)); for (let i = shells.length - 1; i >= 0; i--) {
const endY = originY - (16 + Math.random() * 34); const sh = shells[i];
const ctrlX = originX + horizontalBias * (spread * 0.25) + (Math.random() - 0.5) * 14; const elapsed = now - startTime - sh.start;
const ctrlY = originY - (30 + Math.random() * 55); 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;
}
particles.push({ // soft radial flashes at each burst point
el, for (let i = flashes.length - 1; i >= 0; i--) {
delay, const f = flashes[i];
duration, const p = (now - f.born) / 300;
originX, if (p >= 1) { flashes.splice(i, 1); continue; }
originY, active = true;
ctrlX, const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r);
ctrlY, g.addColorStop(0, 'rgba(255,255,255,' + (0.85 * (1 - p)).toFixed(3) + ')');
endX, g.addColorStop(1, 'rgba(255,255,255,0)');
endY, ctx.fillStyle = g;
rotate: (Math.random() * 260) - 130, ctx.beginPath();
scale: 0.8 + Math.random() * 0.5, 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(); const startTime = performance.now();
let rafId = null; runCelebrationCanvas(view, (now) => {
ctx.clearRect(0, 0, view.w, view.h);
const animate = now => {
let active = false; let active = false;
for (let i = 0; i < sparks.length; i++) {
for (let i = particles.length - 1; i >= 0; i--) { const sp = sparks[i];
const particle = particles[i]; const elapsed = now - startTime - sp.delay;
const elapsed = now - startTime - particle.delay; if (elapsed < 0) { active = true; continue; }
if (elapsed < 0) { const p = Math.min(1, elapsed / sp.dur);
active = true; if (p < 1) active = true;
continue; // 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);
}
}
const progress = Math.min(1, elapsed / particle.duration); // Total ms until the last heart finishes rising (used to hold the task's
const eased = 1 - Math.pow(1 - progress, 3); // 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 x = (1 - eased) * (1 - eased) * particle.originX + 2 * (1 - eased) * eased * particle.ctrlX + eased * eased * particle.endX; function createRisingHearts(targetElement) {
const y = (1 - eased) * (1 - eased) * particle.originY + 2 * (1 - eased) * eased * particle.ctrlY + eased * eased * particle.endY; 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);
}
}
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)})`; // The rocket exit flies the task element itself off the page (via the
particle.el.style.opacity = String(1 - progress); // canvasrefined-rocket-exit keyframes applied by applyTodoExitAnimation);
// this spawns the exhaust flame trail that follows the flying card.
if (progress < 1) { function createRocketCelebration(targetElement) {
active = true; 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);
}
}
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 { } else {
particle.el.remove(); // Let the last flames finish, then drop the whole container.
particles.splice(i, 1); setTimeout(() => { try { container.remove(); } catch (e) { /* ignore */ } }, 1100);
}
}
if (active) {
rafId = requestAnimationFrame(animate);
} else {
try { container.remove(); } catch (e) { /* ignore */ }
if (rafId) cancelAnimationFrame(rafId);
} }
}; };
rafId = requestAnimationFrame(tick);
rafId = requestAnimationFrame(animate);
// cleanup container after animations
setTimeout(() => {
try { container.remove(); } catch (e) { /* ignore */ }
}, 2400);
} catch (e) { } 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 = `
<div style="display:flex;align-items:center;gap:5px;width:100%;height:60px;background:var(--bcbackground-2);border-radius:5px;transition:all .4s ease;overflow:hidden;">
<div style="width:40px;display:flex;align-items:center;justify-content:center;background-color:#56caf0;height:100%;border-radius:5px 0 0 5px;">
<div style="width:20px;height:20px;display:flex;margin-left:5px;">
<svg fill="var(--cr-todo-icon)" viewBox="0 0 1920 1920" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;display:block;">
<path d="M1468.214 0v551.145L840.27 1179.089c-31.623 31.623-49.693 74.54-49.693 119.715v395.289h395.288c45.176 0 88.093-18.07 119.716-49.694l162.633-162.633v438.206H0V0h1468.214Zm129.428 581.3c22.137-22.136 57.825-22.136 79.962 0l225.879 225.879c22.023 22.023 22.023 57.712 0 79.848l-677.638 677.637c-10.616 10.503-24.96 16.49-39.98 16.49H903.516v-282.35c0-15.02 5.986-29.364 16.49-39.867Zm-920.005 548.095H338.82v112.94h338.818v-112.94Zm225.88-225.879H338.818v112.94h564.697v-112.94Zm734.106-202.5-89.561 89.56 146.03 146.031 89.562-89.56-146.031-146.031Zm-508.228-362.197H338.82v338.818h790.576V338.82Z" fill-rule="evenodd"></path>
</svg>
</div>
</div>
<div style="width:calc(100% - 40px);height:80%;display:flex;flex-direction:column;gap:5px;padding-left:2px;box-sizing:border-box;overflow:hidden;position:relative;">
<div style="display:flex;flex-direction:column;gap:3px;">
<span style="color:var(--bctext-0);font-size:12px;margin-top:-2px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;box-sizing:border-box;padding-right:22px;">Celebration preview</span>
<span style="color:inherit;text-decoration:none;font-weight:bold;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box;padding-right:28px;margin-top:-5px;">Sample task</span>
<span style="color:var(--bctext-0);font-size:12px;margin-top:-5px;">Due today</span>
</div>
</div>
</div>`;
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 = item.planner_override || {};
item.planner_override.marked_complete = completeState; item.planner_override.marked_complete = completeState;
// fire confetti only when marking complete (not when unmarking). // A previous markAs on this same element may still have celebration or
// Must happen BEFORE the slide-out transform below: the burst measures // exit timers pending (fast double-click). Cancel them so the new action
// the element with getBoundingClientRect(), which reflects transforms — // 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 // measuring after translate(100%) would place the origin one full card
// width to the right (off screen) whenever the transform applies // width to the right (off screen) whenever the transform applies
// instantly, i.e. on cards without a transition (announcements). // instantly, i.e. on cards without a transition (announcements).
let exitStyle = "slide";
let exitDelay = 0;
if (completeState) { 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%)"; applyTodoExitAnimation(element, exitStyle, exitDelay);
element.style.opacity = "0";
// update progress rings immediately so they animate while the item slides/fades // update progress rings immediately so they animate while the item slides/fades
const refreshProgressRings = () => { const refreshProgressRings = () => {
@ -4737,9 +5366,26 @@ function markAs(item, element, makeComplete) {
setPinnedIncomplete(item, false); setPinnedIncomplete(item, false);
} }
console.warn("planner override not persisted, reverting UI:", reason); 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.transform = "";
element.style.opacity = ""; 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"); const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); } if (loc) { clearTodoList(); createTodoSections(loc); }
refreshProgressRings(); refreshProgressRings();
@ -4748,7 +5394,7 @@ function markAs(item, element, makeComplete) {
setTimeout(() => { setTimeout(() => {
clearTodoList(); clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list")); createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}, 400); }, celebrationExitDuration(exitStyle, exitDelay));
// --- Persistence (background, best-effort) --- // --- Persistence (background, best-effort) ---
// Toggle the planner override server-side. The override id returned by a // Toggle the planner override server-side. The override id returned by a

View File

@ -1,11 +1,12 @@
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 = [ const syncedSubOptions = [
"grade_analytics_zones", "grade_analytics_zones",
"hide_course_images", "hide_course_images",
"todo_hide_feedback", "todo_hide_feedback",
"todo_hide_read", "todo_hide_read",
"todo_full_height", "todo_full_height",
"todo_confetti", "todo_celebrations",
"todo_celebration_amount",
"todo_progress_rings", "todo_progress_rings",
"todo_timeframe", "todo_timeframe",
"todo_timeframe_custom_past", "todo_timeframe_custom_past",
@ -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 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 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 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 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"]; 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, // 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_past": 7,
"todo_timeframe_custom_future": 14, "todo_timeframe_custom_future": 14,
"todo_more_expanded": false, "todo_more_expanded": false,
"todo_confetti": true, "todo_celebrations_enabled": true,
"todo_celebrations": ["confetti"],
"todo_celebration_amount": "normal",
"device_dark": false, "device_dark": false,
"cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 }, "cumulative_gpa": { "name": "Cumulative GPA", "hidden": false, "weight": "dnc", "credits": 999, "gr": 3.21 },
// "show_updates": false, // "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 => `
<label class="celebration-option">
<input type="checkbox" value="${t.id}" ${selected.includes(t.id) ? "checked" : ""}>
<span>${t.label}</span>
</label>
`).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. // Collapsible "More options" section for the Better Todo List sub-options.
// Persists whether it's expanded so the popup reopens in the same state. // Persists whether it's expanded so the popup reopens in the same state.
function setupTodoMoreOptions(initial) { function setupTodoMoreOptions(initial) {
@ -1246,7 +1371,6 @@ function setup() {
// /*'card_method_date',*/ "show_updates", // /*'card_method_date',*/ "show_updates",
"todo_hide_feedback", "todo_hide_feedback",
"todo_hide_read", "todo_hide_read",
"todo_confetti",
"todo_full_height", "todo_full_height",
"device_dark", "device_dark",
"relative_dues", "relative_dues",
@ -1424,6 +1548,18 @@ function setup() {
{ {
identifier: "customBackgroundScale", identifier: "customBackgroundScale",
setup: (initial) => setupCustomBackgroundScale(initial), 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", identifier: "todo_progress_rings",
@ -1467,7 +1603,7 @@ function setup() {
chrome.storage.sync.get(menu.switches, sync => { chrome.storage.sync.get(menu.switches, sync => {
menu.switches.forEach(option => { menu.switches.forEach(option => {
let optionSwitch = document.getElementById(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).checked = true;
optionSwitch.querySelector(status).classList.add('checked'); optionSwitch.querySelector(status).classList.add('checked');
@ -1483,6 +1619,12 @@ function setup() {
if (option === "better_sidebar") { if (option === "better_sidebar") {
toggleBetterSidebarSubOptions(status); toggleBetterSidebarSubOptions(status);
} }
if (option === "better_todo") {
toggleCelebrationsVisibility(status);
}
if (option === "todo_celebrations_enabled") {
toggleCelebrationOptionsVisibility(status);
}
if (option === "dark_mode") { if (option === "dark_mode") {
toggleAlternateColorsVisibility(status); toggleAlternateColorsVisibility(status);
} }
@ -1499,6 +1641,10 @@ function setup() {
}); });
}); });
toggleBetterSidebarSubOptions(sync["better_sidebar"] === true); 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 => { ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => {
toggleSubOptionsVisibility(opt, sync[opt] === true); toggleSubOptionsVisibility(opt, sync[opt] === true);
}); });
@ -2324,7 +2470,9 @@ function saveCurrentTheme() {
"todo_hide_feedback": current["todo_hide_feedback"], "todo_hide_feedback": current["todo_hide_feedback"],
"todo_hide_read": current["todo_hide_read"] !== false, "todo_hide_read": current["todo_hide_read"] !== false,
"todo_full_height": current["todo_full_height"], "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_progress_rings": current["todo_progress_rings"],
"todo_timeframe": current["todo_timeframe"], "todo_timeframe": current["todo_timeframe"],
"todo_timeframe_custom_past": current["todo_timeframe_custom_past"], "todo_timeframe_custom_past": current["todo_timeframe_custom_past"],
@ -2509,6 +2657,11 @@ function importTheme(theme) {
} }
break; 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: default:
final[key] = theme[key]; final[key] = theme[key];
break; break;