diff --git a/html/popup.html b/html/popup.html index 1bb726a..78ba84f 100644 --- a/html/popup.html +++ b/html/popup.html @@ -2,6 +2,7 @@ + Canvas Refined @@ -839,6 +840,44 @@ +
+

Card Grid

+
+ + +
+ +
+

Missing cards?

diff --git a/js/content.js b/js/content.js index 84fd1d3..11ab4b6 100644 --- a/js/content.js +++ b/js/content.js @@ -993,6 +993,10 @@ function applyOptionsChanges(changes) { case "gpa_calc_cumulative": if (!grades) getGrades(); setupGPACalc(); + // The GPA elements are placed on dedicated grid rows when Card + // Grid is on (see applyAestheticChanges), so toggling GPA on/off + // has to regenerate the grid CSS too. + if (options.card_grid === true) debouncedApplyAestheticChanges(); break; case "gpa_calc_bounds": calculateGPA2(); @@ -1064,6 +1068,12 @@ function applyOptionsChanges(changes) { case "cardWidth": case "cardHeight": case "cardPadding": + case "card_grid": + case "card_grid_columns": + case "card_grid_rows": + case "card_grid_column_gap": + case "card_grid_row_gap": + case "card_grid_center_rows": case "customCardStyles": // Coalesce rapid card-style edits (e.g. holding the arrow keys on a // number input) into a single applyAestheticChanges() call. Each @@ -1117,6 +1127,14 @@ function applyOptionsChanges(changes) { break; } case "better_sidebar": + // Better Sidebar mounts its whole layout on page load, so either + // direction of the toggle reloads the page for a clean mount/ + // removal. The old-value guard keeps a re-save of the same value + // from looping the reload. + if (changes["better_sidebar"]?.oldValue !== options.better_sidebar) { + window.location.reload(); + break; + } if (options.better_sidebar) { ensureBetterSidebar(); } else { @@ -5881,6 +5899,10 @@ function customizeCards(c = null) { }); + // Hiding/unhiding cards changes how many cards sit in each grid row, + // so recompute the uneven-row centering offset after visibility is set. + centerUnevenGridRows(); + } catch (e) { logError(e); } @@ -6579,6 +6601,49 @@ function applyAestheticChanges() { if (options.disable_color_overlay === true) style.textContent += ".ic-DashboardCard__header_hero{opacity: 0!important} .ic-DashboardCard__header-button-bg{opacity: 1!important}"; if (options.full_width === true) style.textContent += "#wrapper,.ic-Layout-wrapper{max-width:100%!important}"; if (options.center_cards === true) style.textContent += ".ic-DashboardCard__box__container{display:flex!important;flex-wrap:wrap!important;justify-content:center!important;align-items:flex-start!important}"; + // Card Grid: lay the dashboard card container out as a strict columns x + // rows grid chosen in the popup, with separate column/row spacing. + // Added after center_cards so it wins the !important tie-break if both + // are on. Hidden cards are display:none, so they don't take up grid + // cells. Cards beyond the chosen cells fall into zero-height implicit + // rows that overflow-hidden clips away — hence the "cards may be cut + // off" warning in the popup. When Center Cards is also on, columns size + // to the cards' natural width (they have a fixed width anyway) and the + // whole grid is centered horizontally, mirroring what Center Cards does + // in flex mode. + // + // Columns are doubled into "sub-columns" (each card spans 2 of them) so + // "Center uneven rows" can shift a partial last row by half a card — + // e.g. 3 cards under a 4-card row has exactly one column of leftover + // space, which needs a half-column offset to look centered. Empty + // sub-columns just make the odd case possible; full rows are unaffected. + // Tracks are always max-content (Canvas cards have a fixed width, so the + // tracks hug them exactly): spacing stays identical whether Center Cards + // is on or off, instead of 1fr stretching cells and widening the visual + // gaps. Center Cards then just centers the whole grid with + // justify-content:center. + // + // The GPA calculator elements live in this same container (appended by + // setupGPACalc), so when they're visible they get definite full-width + // rows past the card rows — otherwise they'd take grid cells, land in + // the middle of the card flow (they can be prepended), and throw off the + // row math. Two spare auto-height template rows host them; when the GPA + // feature is off they're display:none and the spare rows collapse to 0. + if (options.card_grid === true) { + const gridCols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4); + const gridRows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); + const gridColGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12); + const gridRowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12); + const centerGrid = options.center_cards === true; + const gpaOn = options.gpa_calc === true; + const totalRows = gridRows + (gpaOn ? 2 : 0); + style.textContent += `.ic-DashboardCard__box__container{display:grid!important;grid-template-columns:repeat(${gridCols * 2},minmax(0,max-content))!important;grid-template-rows:repeat(${totalRows},minmax(0,auto))!important;grid-auto-rows:0!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important;overflow:hidden!important${centerGrid ? ";justify-content:center!important" : ""}}`; + style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard{grid-column:span 2!important}`; + if (gpaOn) { + style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:${gridRows + 1}!important;grid-column:span 2!important}`; + style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:${gridRows + 2}!important;grid-column:1/-1!important}`; + } + } if (options.customCardStyles === true) { if (options.imageSize !== undefined && options.imageSize !== 100) style.textContent += `.ic-DashboardCard__header_image {transform: scale(${options.imageSize / 100})!important; }`; if (options.cardRoundness !== undefined && options.cardRoundness !== 5) style.textContent += `.ic-DashboardCard {border-radius: ${options.cardRoundness}px!important;}`; @@ -6624,6 +6689,59 @@ function applyAestheticChanges() { if (options.hide_navbar === true) style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}"; if (options.custom_styles !== "") style.textContent += options.custom_styles; document.documentElement.appendChild(style); + // Uneven-row centering needs the real cards (it offsets the first card of + // the last partial row), so schedule it just after the style lands. Run + // unconditionally while any grid setting changes: the function clears + // stale offsets first, which is what makes turning the option (or the + // whole grid) OFF take effect without a page refresh. + requestAnimationFrame(centerUnevenGridRows); +} + +/* +Center uneven grid rows. + +CSS grid always packs rows from the left, so a last row with fewer cards +than the chosen column count sits flush-left while the rows above are full. +Pure CSS can't center a partial row, so we offset the first card of the last +partial row with grid-column-start. + +The grid runs on double sub-columns (each card spans 2 — see +applyAestheticChanges), which is what makes this possible at all: with 3 +cards under a 4-card row the leftover space is one column, and centering +needs a HALF-column shift — impossible on plain column lines. On sub-columns +the shift is a whole sub-column (half a card + half a gap), so every +remainder can be centered exactly. + +Only runs while Card Grid is on. Skips when the cards overflow the explicit +template (those extra rows are clipped anyway, and the last visible row is +full). Any previously applied offset is cleared first so re-runs (card +hidden/unhidden, dashboard re-render, setting changes) start clean. +*/ +function centerUnevenGridRows() { + const container = document.querySelector(".ic-DashboardCard__box__container"); + if (!container) return; + const cards = Array.from(container.children).filter(el => el.classList && el.classList.contains("ic-DashboardCard")); + cards.forEach(card => card.style.removeProperty("grid-column")); + if (options.card_grid !== true || options.card_grid_center_rows !== true) return; + const cols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4); + const rows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); + const visible = cards.filter(card => getComputedStyle(card).display !== "none"); + const count = visible.length; + // Overflowing the template: extra cards land in clipped implicit rows and + // the last explicit row is full, so there's nothing to center. + if (count === 0 || count >= cols * rows) return; + const lastRowStart = Math.floor((count - 1) / cols) * cols; + const inLastRow = count - lastRowStart; + if (inLastRow >= cols) return; + // Offset in sub-columns: each card spans 2 sub-columns, so the leftover + // space is 2 * (cols - inLastRow) sub-columns and centering shifts by + // half of it. The explicit start must be paired with an explicit span, + // or the card would collapse to a single sub-column. Both are set with + // "important" priority because the stylesheet span rule is !important + // too — plain inline styles would lose to it and the offset would never + // take effect. + const offset = cols - inLastRow; + if (offset > 0) visible[lastRowStart].style.setProperty("grid-column", `${offset + 1} / span 2`, "important"); } /* diff --git a/js/popup.js b/js/popup.js index b8d3504..761c1a8 100644 --- a/js/popup.js +++ b/js/popup.js @@ -40,6 +40,15 @@ const syncedSubOptions = [ "cardWidth", "cardHeight", "cardPadding", + // Card Grid settings — included here so theme exports, saved themes and + // previous-theme snapshots actually read them from storage (getExport + // drops keys it wasn't given). + "card_grid", + "card_grid_columns", + "card_grid_rows", + "card_grid_column_gap", + "card_grid_row_gap", + "card_grid_center_rows", "customBackgroundLink", "customBackgroundScale", "customBackgroundDaily", @@ -58,7 +67,7 @@ const localSwitches = []; // Theme export only carries visual settings, never personal productivity data. const exportDarkSchedule = ["auto_dark", "auto_dark_start", "auto_dark_end", "device_dark"]; const exportCardColorToggles = ["gradient_cards", "disable_color_overlay"]; -const exportCardStyles = ["customCardStyles", "imageSize", "cardRoundness", "imageRoundness", "cardSpacing", "cardWidth", "cardHeight", "cardPadding"]; +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"]; 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_hr24", "todo_separate_scrollbar", "todo_alternate_colors", "todo_ignore_card_colors", "todo_remove_icons", "todo_show_scores", "hover_preview"]; @@ -186,6 +195,12 @@ const defaultOptions = { "cardWidth": 262, "cardHeight": 146, "cardPadding": 0, + "card_grid": false, + "card_grid_columns": 4, + "card_grid_rows": 3, + "card_grid_column_gap": 12, + "card_grid_row_gap": 12, + "card_grid_center_rows": false, "customCardStyles": false, "customBackgroundLink": "", "customBackgroundScale": 100, @@ -201,9 +216,12 @@ sendFromPopup("getCards"); // refresh the cards if new ones were just recieved chrome.storage.onChanged.addListener((changes) => { if (changes["custom_cards"]) { - if (Object.keys(changes["custom_cards"].oldValue).length !== Object.keys(changes["custom_cards"].newValue).length) { + if (Object.keys(changes["custom_cards"].oldValue || {}).length !== Object.keys(changes["custom_cards"].newValue || {}).length) { displayAdvancedCards(); } + // Hidden-card edits from the card menu change how many cells the + // grid preview should mark as filled. + updateCardGridPreview(); } }); @@ -450,6 +468,109 @@ function setupCardPaddingInput(initial) { }); } +// Card Grid (dashboard card layout). The toggle lives in menu.checkboxes; +// this wires the reveal of the options panel plus the squares preview and +// the "some cards may be cut off" warning. +function setupCardGrid(initial) { + const panel = document.getElementById("card-grid-options"); + if (!panel) return; + panel.style.display = initial === true ? "" : "none"; + // Defer the first render: setupCardGrid runs inside the special-options + // loop BEFORE the column/row/gap inputs get their stored values + // (setupCardGridCountInput), so an immediate render would use the HTML + // defaults and look wrong until a value changes. A 0ms timeout runs once + // the whole synchronous setup pass is done. + setTimeout(() => updateCardGridPreview(), 0); + document.getElementById("card_grid").addEventListener("change", (e) => { + panel.style.display = e.target.checked ? "" : "none"; + if (e.target.checked) updateCardGridPreview(); + }); + // Row-centering only affects the preview's last-row offset. + document.getElementById("card_grid_center_rows").addEventListener("change", () => { + updateCardGridPreview(); + }); +} + +// Grid count/spacing inputs for the card grid. Update the preview +// immediately on every keystroke, but debounce the storage write like the +// other card style number inputs. `fallback` is used when the field is +// cleared/invalid — 1 for counts, 0 for spacings. +function setupCardGridCountInput(id, initial, fallback = 1) { + const el = document.getElementById(id); + if (!el) return; + el.value = initial; + el.addEventListener("input", (e) => { + updateCardGridPreview(); + const num = parseInt(e.target.value, 10); + debouncedCardStyleSet(id, isNaN(num) ? fallback : num); + }); +} + +// Number of dashboard cards that will occupy grid cells: every stored card +// that isn't hidden via the card menu. Hidden cards get display:none in the +// dashboard, so they don't take up a grid slot. +function countActiveDashboardCards(cards) { + return Object.values(cards || {}).filter(c => c && typeof c === "object" && c.hidden !== true && c.hide !== true).length; +} + +// Squares preview: one square per grid cell. Filled squares = cells covered +// by an active (non-hidden) card; dim squares = empty cells. Warns when the +// grid can't fit every active card. +function updateCardGridPreview() { + const panel = document.getElementById("card-grid-options"); + const preview = document.getElementById("card-grid-preview"); + if (!panel || !preview || panel.style.display === "none") return; + const cols = Math.max(1, parseInt(document.getElementById("card_grid_columns").value, 10) || 1); + const rows = Math.max(1, parseInt(document.getElementById("card_grid_rows").value, 10) || 1); + const colGapRaw = parseInt(document.getElementById("card_grid_column_gap").value, 10); + const colGap = Math.max(0, Math.min(10, isNaN(colGapRaw) ? 12 : colGapRaw)); + const total = cols * rows; + preview.style.gridTemplateColumns = `repeat(${cols}, 18px)`; + // Echo the chosen column spacing in the preview (clamped so extreme + // values don't blow the layout out). + preview.style.columnGap = `${colGap}px`; + preview.style.rowGap = "4px"; + preview.innerHTML = ""; + chrome.storage.sync.get("custom_cards", s => { + // Grid may have been toggled off while the storage read was pending. + if (document.getElementById("card-grid-options").style.display === "none") return; + const active = countActiveDashboardCards(s.custom_cards); + // Uneven-row centering: the real grid shifts the last partial row in + // half-card steps (double sub-columns — see centerUnevenGridRows). + // In the preview that's a margin on every square of that row; since + // the squares shift into space where no neighbor sits, nothing + // overlaps. + let lastRowStart = -1, offset = 0; + if (document.getElementById("card_grid_center_rows")?.checked === true && active > 0 && active < total) { + lastRowStart = Math.floor((active - 1) / cols) * cols; + const inLastRow = active - lastRowStart; + if (inLastRow < cols) offset = cols - inLastRow; + } + for (let i = 0; i < total; i++) { + const inShiftedRow = lastRowStart !== -1 && i >= lastRowStart; + // The shifted row's empty cells aren't rendered — the row's filled + // squares carry the offset, so the empties would misrepresent it. + if (inShiftedRow && i >= active) continue; + const square = document.createElement("div"); + square.style.cssText = "width:18px;height:18px;border-radius:4px;box-sizing:border-box;"; + square.style.background = i < active ? "#56Caf0" : "#3c3c3c"; + if (inShiftedRow && offset > 0) { + square.style.marginLeft = `${offset * (18 + colGap) / 2}px`; + } + preview.appendChild(square); + } + const warning = document.getElementById("card-grid-warning"); + if (warning) { + if (active > total) { + warning.textContent = `\u26A0 Your grid holds ${total} card${total === 1 ? "" : "s"} but you have ${active} active card${active === 1 ? "" : "s"} - some cards may be cut off.`; + warning.style.display = "block"; + } else { + warning.style.display = "none"; + } + } + }); +} + function setupCustomBackgroundLink(initial) { let el = document.querySelector("#customBackgroundLink"); el.value = initial || ""; @@ -980,6 +1101,8 @@ function setup() { "fitImageToScreen", "card_transparency", "customCardStyles", + "card_grid", + "card_grid_center_rows", "grade_analytics_zones", ], tabs: { @@ -1097,6 +1220,26 @@ function setup() { identifier: "cardPadding", setup: (initial) => setupCardPaddingInput(initial), }, + { + identifier: "card_grid", + setup: (initial) => setupCardGrid(initial), + }, + { + identifier: "card_grid_columns", + setup: (initial) => setupCardGridCountInput("card_grid_columns", initial), + }, + { + identifier: "card_grid_rows", + setup: (initial) => setupCardGridCountInput("card_grid_rows", initial), + }, + { + identifier: "card_grid_column_gap", + setup: (initial) => setupCardGridCountInput("card_grid_column_gap", initial, 0), + }, + { + identifier: "card_grid_row_gap", + setup: (initial) => setupCardGridCountInput("card_grid_row_gap", initial, 0), + }, { identifier: "customBackgroundLink", setup: (initial) => setupCustomBackgroundLink(initial), @@ -1906,6 +2049,15 @@ function saveCurrentTheme() { "cardSpacing": current["cardSpacing"], "cardWidth": current["cardWidth"], "cardHeight": current["cardHeight"], + "cardPadding": current["cardPadding"], + // Card Grid (part of card styles): layout toggle, counts, + // spacing and uneven-row centering. + "card_grid": current["card_grid"], + "card_grid_columns": current["card_grid_columns"], + "card_grid_rows": current["card_grid_rows"], + "card_grid_column_gap": current["card_grid_column_gap"], + "card_grid_row_gap": current["card_grid_row_gap"], + "card_grid_center_rows": current["card_grid_center_rows"], "custom_styles": current["custom_styles"], "customCardStyles": current["customCardStyles"], "customBackgroundLink": current["customBackgroundLink"],