card grid and custom styles to widgets

This commit is contained in:
Guy Sandler 2026-10-25 13:56:04 -07:00
parent 189783febd
commit d0d3ba4de2
15 changed files with 337 additions and 125 deletions

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Flexibles Raster" "message": "Flexibles Raster"
}, },
"flexible_grid_on": {
"message": "Das flexible Raster ist aktiv – Karten umbrechen, um in schmale Fenster zu passen, statt abgeschnitten zu werden."
},
"focus_timer": { "focus_timer": {
"message": "Fokus-Timer" "message": "Fokus-Timer"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Wähle 2 Farben, um einen Verlauf zwischen ihnen zu erstellen:" "message": "Wähle 2 Farben, um einen Verlauf zwischen ihnen zu erstellen:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (einschließlich des GPA-Rechners)" "message": " (einschließlich {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "Dein Raster bietet Platz für {{TOTAL}} Karte{{TOTAL_S}}, aber du hast {{ACTIVE}} Karte{{ACTIVE_S}}{{GPA_NOTE}} – einige Karten werden möglicherweise abgeschnitten." "message": "Dein Raster bietet Platz für {{TOTAL}} Karte{{TOTAL_S}}, aber du hast {{ACTIVE}} Karte{{ACTIVE_S}}{{GPA_NOTE}} – einige Karten werden möglicherweise abgeschnitten."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Flexible grid" "message": "Flexible grid"
}, },
"flexible_grid_on": {
"message": "Flexible grid is on - cards wrap to fit narrow windows instead of being cut off."
},
"focus_timer": { "focus_timer": {
"message": "Focus Timer" "message": "Focus Timer"
}, },
@ -863,8 +860,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Choose 2 colors to create a gradient between them:" "message": "Choose 2 colors to create a gradient between them:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (including the GPA calculator)" "message": " (including {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "Your grid holds {{TOTAL}} card{{TOTAL_S}} but you have {{ACTIVE}} card{{ACTIVE_S}}{{GPA_NOTE}} - some cards may be cut off." "message": "Your grid holds {{TOTAL}} card{{TOTAL_S}} but you have {{ACTIVE}} card{{ACTIVE_S}}{{GPA_NOTE}} - some cards may be cut off."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Cuadrícula flexible" "message": "Cuadrícula flexible"
}, },
"flexible_grid_on": {
"message": "La cuadrícula flexible está activada: las tarjetas se ajustan al ancho de las ventanas estrechas en lugar de quedar cortadas."
},
"focus_timer": { "focus_timer": {
"message": "Temporizador de enfoque" "message": "Temporizador de enfoque"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Elige 2 colores para crear un degradado entre ellos:" "message": "Elige 2 colores para crear un degradado entre ellos:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (incluida la calculadora de GPA)" "message": " (incluidos {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "Tu cuadrícula admite {{TOTAL}} tarjeta{{TOTAL_S}} pero tienes {{ACTIVE}} tarjeta{{ACTIVE_S}}{{GPA_NOTE}} - algunas tarjetas pueden quedar cortadas." "message": "Tu cuadrícula admite {{TOTAL}} tarjeta{{TOTAL_S}} pero tienes {{ACTIVE}} tarjeta{{ACTIVE_S}}{{GPA_NOTE}} - algunas tarjetas pueden quedar cortadas."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Grille flexible" "message": "Grille flexible"
}, },
"flexible_grid_on": {
"message": "La grille flexible est active — les cartes s'enroulent pour s'adapter aux fenêtres étroites au lieu d'être coupées."
},
"focus_timer": { "focus_timer": {
"message": "Minuteur de concentration" "message": "Minuteur de concentration"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Choisissez 2 couleurs pour créer un dégradé entre elles :" "message": "Choisissez 2 couleurs pour créer un dégradé entre elles :"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (y compris le calculateur GPA)" "message": " (y compris {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "Votre grille contient {{TOTAL}} carte{{TOTAL_S}} mais vous avez {{ACTIVE}} carte{{ACTIVE_S}}{{GPA_NOTE}} — certaines cartes peuvent être coupées." "message": "Votre grille contient {{TOTAL}} carte{{TOTAL_S}} mais vous avez {{ACTIVE}} carte{{ACTIVE_S}}{{GPA_NOTE}} — certaines cartes peuvent être coupées."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Griglia flessibile" "message": "Griglia flessibile"
}, },
"flexible_grid_on": {
"message": "La griglia flessibile è attiva: le card si adattano alle finestre strette invece di essere tagliate."
},
"focus_timer": { "focus_timer": {
"message": "Timer di concentrazione" "message": "Timer di concentrazione"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Scegli 2 colori per creare una sfumatura tra loro:" "message": "Scegli 2 colori per creare una sfumatura tra loro:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (incluso il calcolatore GPA)" "message": " (inclusi {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "La tua griglia contiene {{TOTAL}} card{{TOTAL_S}} ma ne hai {{ACTIVE}} card{{ACTIVE_S}}{{GPA_NOTE}} - alcune card potrebbero essere tagliate." "message": "La tua griglia contiene {{TOTAL}} card{{TOTAL_S}} ma ne hai {{ACTIVE}} card{{ACTIVE_S}}{{GPA_NOTE}} - alcune card potrebbero essere tagliate."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "フレキシブルグリッド" "message": "フレキシブルグリッド"
}, },
"flexible_grid_on": {
"message": "フレキシブルグリッドが有効です。カードは切れずに折り返して狭いウィンドウに収まります。"
},
"focus_timer": { "focus_timer": {
"message": "集中タイマー" "message": "集中タイマー"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "2色を選んでグラデーションを作成します:" "message": "2色を選んでグラデーションを作成します:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (GPA計算を含む)" "message": " ({{NAMES}}を含む)"
}, },
"grid_warning": { "grid_warning": {
"message": "グリッドには{{TOTAL}}枚のカード{{TOTAL_S}}が入りますが、{{ACTIVE}}枚のカード{{ACTIVE_S}}{{GPA_NOTE}}があります - 一部のカードが切れる可能性があります。" "message": "グリッドには{{TOTAL}}枚のカード{{TOTAL_S}}が入りますが、{{ACTIVE}}枚のカード{{ACTIVE_S}}{{GPA_NOTE}}があります - 一部のカードが切れる可能性があります。"

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Grelha flexível" "message": "Grelha flexível"
}, },
"flexible_grid_on": {
"message": "A grelha flexível está ativa — os cartões ajustam-se para caber em janelas estreitas em vez de serem cortados."
},
"focus_timer": { "focus_timer": {
"message": "Temporizador de foco" "message": "Temporizador de foco"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Escolhe 2 cores para criar um gradiente entre elas:" "message": "Escolhe 2 cores para criar um gradiente entre elas:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (incluindo a calculadora de GPA)" "message": " (incluindo {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "A tua grelha comporta {{TOTAL}} vaga{{TOTAL_S}}, mas tens {{ACTIVE}} vaga{{ACTIVE_S}}{{GPA_NOTE}} - alguns cartões podem ficar cortados." "message": "A tua grelha comporta {{TOTAL}} vaga{{TOTAL_S}}, mas tens {{ACTIVE}} vaga{{ACTIVE_S}}{{GPA_NOTE}} - alguns cartões podem ficar cortados."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Гибкая сетка" "message": "Гибкая сетка"
}, },
"flexible_grid_on": {
"message": "Включена гибкая сетка — карточки переносятся, чтобы помещаться в узкие окна, а не обрезаются."
},
"focus_timer": { "focus_timer": {
"message": "Таймер фокуса" "message": "Таймер фокуса"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Выберите 2 цвета, чтобы создать между ними градиент:" "message": "Выберите 2 цвета, чтобы создать между ними градиент:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (включая калькулятор GPA)" "message": " (включая {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "В вашей сетке помещается {{TOTAL}} карточек{{TOTAL_S}}, но у вас {{ACTIVE}} карточка{{ACTIVE_S}}{{GPA_NOTE}} — некоторые карточки могут обрезаться." "message": "В вашей сетке помещается {{TOTAL}} карточек{{TOTAL_S}}, но у вас {{ACTIVE}} карточка{{ACTIVE_S}}{{GPA_NOTE}} — некоторые карточки могут обрезаться."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "Flexibelt rutnät" "message": "Flexibelt rutnät"
}, },
"flexible_grid_on": {
"message": "Flexibelt rutnät är på – korten radbryts för att passa smala fönster i stället för att klippas av."
},
"focus_timer": { "focus_timer": {
"message": "Fokustimer" "message": "Fokustimer"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "Välj 2 färger för att skapa en gradient mellan dem:" "message": "Välj 2 färger för att skapa en gradient mellan dem:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (inklusive GPA-kalkylatorn)" "message": " (inklusive {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "Ditt rutnät har plats för {{TOTAL}} kort{{TOTAL_S}} men du har {{ACTIVE}} kort{{ACTIVE_S}}{{GPA_NOTE}} – vissa kort kan klippas av." "message": "Ditt rutnät har plats för {{TOTAL}} kort{{TOTAL_S}} men du har {{ACTIVE}} kort{{ACTIVE_S}}{{GPA_NOTE}} – vissa kort kan klippas av."

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "弹性网格" "message": "弹性网格"
}, },
"flexible_grid_on": {
"message": "弹性网格已开启 - 卡片会自动换行以适应窄窗口,而不会被裁切。"
},
"focus_timer": { "focus_timer": {
"message": "专注计时器" "message": "专注计时器"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "选择 2 种颜色以在它们之间创建渐变:" "message": "选择 2 种颜色以在它们之间创建渐变:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (包括 GPA 计算器)" "message": " (包括 {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "你的网格可容纳 {{TOTAL}} 张卡片{{TOTAL_S}},但你有 {{ACTIVE}} 张卡片{{ACTIVE_S}}{{GPA_NOTE}} - 部分卡片可能会被裁切。" "message": "你的网格可容纳 {{TOTAL}} 张卡片{{TOTAL_S}},但你有 {{ACTIVE}} 张卡片{{ACTIVE_S}}{{GPA_NOTE}} - 部分卡片可能会被裁切。"

View File

@ -578,9 +578,6 @@
"flexible_grid": { "flexible_grid": {
"message": "彈性格線" "message": "彈性格線"
}, },
"flexible_grid_on": {
"message": "彈性格線已開啟——卡片會自動換行以配合狹窄視窗,而不會被裁切。"
},
"focus_timer": { "focus_timer": {
"message": "專注計時器" "message": "專注計時器"
}, },
@ -860,8 +857,8 @@
"gradient_desc": { "gradient_desc": {
"message": "選擇 2 種顏色,在它們之間建立漸層:" "message": "選擇 2 種顏色,在它們之間建立漸層:"
}, },
"grid_includes_gpa": { "grid_includes_widgets": {
"message": " (包含 GPA 計算器)" "message": " (包含 {{NAMES}})"
}, },
"grid_warning": { "grid_warning": {
"message": "你的格線可容納 {{TOTAL}} 張卡片{{TOTAL_S}},但你目前有 {{ACTIVE}} 張卡片{{ACTIVE_S}}{{GPA_NOTE}}——部分卡片可能會被裁切。" "message": "你的格線可容納 {{TOTAL}} 張卡片{{TOTAL_S}},但你目前有 {{ACTIVE}} 張卡片{{ACTIVE_S}}{{GPA_NOTE}}——部分卡片可能會被裁切。"

View File

@ -1149,8 +1149,13 @@
<div class="option-container"> <div class="option-container">
<h3 class="header-small" data-i18n="top_bottom_height">Top / Bottom height</h3> <h3 class="header-small" data-i18n="top_bottom_height">Top / Bottom height</h3>
<div class="sub-option widget-inline-row"> <div class="sub-option widget-inline-row">
<label for="widget_row_height" class="sub-text widget-inline-label" data-i18n="height">Height</label> <label for="widget_top_height" class="sub-text widget-inline-label" data-i18n="pos_top">Top</label>
<input type="number" id="widget_row_height" class="card-input" min="0" max="2000" data-i18n-placeholder="auto" placeholder="auto" style="width:92px;margin:0;"> <input type="number" id="widget_top_height" class="card-input" min="0" max="2000" data-i18n-placeholder="auto" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span>
</div>
<div class="sub-option widget-inline-row" style="margin-top:6px;">
<label for="widget_bottom_height" class="sub-text widget-inline-label" data-i18n="pos_below">Bottom</label>
<input type="number" id="widget_bottom_height" class="card-input" min="0" max="2000" data-i18n-placeholder="auto" placeholder="auto" style="width:92px;margin:0;">
<span class="sub-text">px</span> <span class="sub-text">px</span>
</div> </div>
</div> </div>

View File

@ -58,6 +58,8 @@ chrome.runtime.onInstalled.addListener(function () {
"quick_links_position": "cards_end", "quick_links_position": "cards_end",
"heatmap": false, "heatmap": false,
"heatmap_position": "below", "heatmap_position": "below",
"widget_top_height": 0,
"widget_bottom_height": 0,
"widget_row_height": 0, "widget_row_height": 0,
"dashboard_notes_height": 0, "dashboard_notes_height": 0,
"gpa_calc_height": 0, "gpa_calc_height": 0,
@ -180,6 +182,16 @@ chrome.runtime.onInstalled.addListener(function () {
newSyncOptions["fitImageToScreen"] = sync["nasaFitToScreen"]; newSyncOptions["fitImageToScreen"] = sync["nasaFitToScreen"];
} }
// The Top/Bottom widget rows used to share one height: give both rows the
// user's old value instead of dropping it to "auto".
if (sync["widget_top_height"] === undefined && sync["widget_bottom_height"] === undefined) {
const legacyRowHeight = Number(sync["widget_row_height"]);
if (Number.isFinite(legacyRowHeight) && legacyRowHeight > 0) {
newSyncOptions["widget_top_height"] = legacyRowHeight;
newSyncOptions["widget_bottom_height"] = legacyRowHeight;
}
}
if (Object.keys(newLocalOptions).length > 0) { if (Object.keys(newLocalOptions).length > 0) {
chrome.storage.local.set(newLocalOptions); chrome.storage.local.set(newLocalOptions);
} }

View File

@ -1090,6 +1090,7 @@ function applyOptionsChanges(changes) {
case "dashboard_notes_position": case "dashboard_notes_position":
case "dashboard_notes_height": case "dashboard_notes_height":
loadDashboardNotes(); loadDashboardNotes();
refreshCardGridLayout();
break; break;
case "focus_timer": case "focus_timer":
case "focus_timer_position": case "focus_timer_position":
@ -1098,6 +1099,7 @@ function applyOptionsChanges(changes) {
case "focus_timer_preset_2": case "focus_timer_preset_2":
case "focus_timer_preset_3": case "focus_timer_preset_3":
loadFocusTimer(); loadFocusTimer();
refreshCardGridLayout();
break; break;
case "breathing": case "breathing":
case "breathing_position": case "breathing_position":
@ -1106,21 +1108,25 @@ function applyOptionsChanges(changes) {
case "breathing_preset_2": case "breathing_preset_2":
case "breathing_preset_3": case "breathing_preset_3":
loadBreathingWidget(); loadBreathingWidget();
refreshCardGridLayout();
break; break;
case "daily_quotes": case "daily_quotes":
case "daily_quotes_position": case "daily_quotes_position":
case "daily_quotes_height": case "daily_quotes_height":
loadDailyQuotesWidget(); loadDailyQuotesWidget();
refreshCardGridLayout();
break; break;
case "quick_links": case "quick_links":
case "quick_links_position": case "quick_links_position":
case "quick_links_height": case "quick_links_height":
loadQuickLinksWidget(); loadQuickLinksWidget();
refreshCardGridLayout();
break; break;
case "heatmap": case "heatmap":
case "heatmap_position": case "heatmap_position":
case "heatmap_height": case "heatmap_height":
loadHeatmapWidget(); loadHeatmapWidget();
refreshCardGridLayout();
break; break;
case "quick_links_font_size": case "quick_links_font_size":
case "quick_links_icon_size": case "quick_links_icon_size":
@ -1144,7 +1150,10 @@ function applyOptionsChanges(changes) {
if (options.dashboard_notes === true) loadDashboardNotes(); if (options.dashboard_notes === true) loadDashboardNotes();
if (options.gpa_calc === true && current_page === "/") setupGPACalc(); if (options.gpa_calc === true && current_page === "/") setupGPACalc();
loadAllSimpleWidgets(); loadAllSimpleWidgets();
refreshCardGridLayout();
break; break;
case "widget_top_height":
case "widget_bottom_height":
case "widget_row_height": case "widget_row_height":
document.querySelectorAll(".canvasrefined-widget-row").forEach(row => { document.querySelectorAll(".canvasrefined-widget-row").forEach(row => {
applyWidgetRowLayout(row, row.dataset.area); applyWidgetRowLayout(row, row.dataset.area);
@ -1267,7 +1276,7 @@ function applyOptionsChanges(changes) {
setupGPACalc(); setupGPACalc();
updateGpaHoverBlur(); updateGpaHoverBlur();
// GPA elements take part in the grid layout, so regenerate the grid CSS. // GPA elements take part in the grid layout, so regenerate the grid CSS.
if (options.card_grid === true) debouncedApplyAestheticChanges(); refreshCardGridLayout();
break; break;
case "gpa_calc_bounds": case "gpa_calc_bounds":
calculateGPA2(); calculateGPA2();
@ -7369,8 +7378,7 @@ function widgetContainerAnchor(container) {
return document.querySelector("#DashboardCard_Container") || container; return document.querySelector("#DashboardCard_Container") || container;
} }
function ensureWidgetRow(area, container) { function ensureWidgetRow(area, container) { const parent = container.parentNode;
const parent = container.parentNode;
if (!parent) return null; if (!parent) return null;
let row = parent.querySelector(":scope > .canvasrefined-widget-row[data-area='" + area + "']"); let row = parent.querySelector(":scope > .canvasrefined-widget-row[data-area='" + area + "']");
if (!row) { if (!row) {
@ -7383,12 +7391,24 @@ function ensureWidgetRow(area, container) {
return row; return row;
} }
// Row height in px for the Top / Bottom widget rows (0 = auto). The two rows have
// their own setting now; older builds shared one value (widget_row_height), which is
// still honoured until storage has been migrated. Row areas are "top"/"below", the
// stored settings are "Top"/"Bottom".
const WIDGET_ROW_HEIGHT_KEYS = { top: "widget_top_height", below: "widget_bottom_height" };
function widgetRowHeight(area) {
const own = parseInt(options[WIDGET_ROW_HEIGHT_KEYS[area]], 10);
if (Number.isFinite(own)) return Math.max(0, own);
const legacy = parseInt(options.widget_row_height, 10);
return Number.isFinite(legacy) ? Math.max(0, legacy) : 0;
}
function applyWidgetRowLayout(row, area) { function applyWidgetRowLayout(row, area) {
const slots = [...row.querySelectorAll(":scope > .canvasrefined-widget-slot")]; const slots = [...row.querySelectorAll(":scope > .canvasrefined-widget-slot")];
if (!slots.length) { row.remove(); return; } if (!slots.length) { row.remove(); return; }
// Shared row height (px; 0 = auto) so every widget in the row matches. // Shared row height (px; 0 = auto) so every widget in the row matches.
const rowHeight = parseInt(options.widget_row_height, 10); const rowHeight = widgetRowHeight(area);
if (Number.isFinite(rowHeight) && rowHeight > 0) row.style.height = rowHeight + "px"; if (rowHeight > 0) row.style.height = rowHeight + "px";
else row.style.height = ""; else row.style.height = "";
const entries = containerWidgetOrder(area); const entries = containerWidgetOrder(area);
const byId = {}; const byId = {};
@ -9493,6 +9513,74 @@ function debouncedApplyAestheticChanges(delay = 150) {
}, delay); }, delay);
} }
// In-card widgets take grid cells and rows, so the template has to be rebuilt when
// one is toggled, moved or resized.
function refreshCardGridLayout() {
if (options.card_grid === true) debouncedApplyAestheticChanges();
}
/*
Card Grid geometry (shared by the layout CSS and centerUnevenGridRows)
The grid has to know about every child that takes a cell or a row: the course
cards, the widgets placed Before/After cards (notes, focus timer, breathing,
quotes, quick links, heatmap) and the GPA summary card. The calculator itself
spans a full row when it is open inside the grid.
*/
const CARD_GRID_HOST = ".ic-DashboardCard__box__container";
// One card-sized cell: course cards, in-card widgets and the GPA summary card.
// (The GPA calculator `.canvasrefined-gpa` is not one: it spans a whole row.)
// `:not(.container-mode)` on the GPA keeps these rules above the card-style margins
// that follow them in the sheet.
const CARD_GRID_CELLS = [
`${CARD_GRID_HOST} > .ic-DashboardCard`,
`${CARD_GRID_HOST} > .canvasrefined-widget-card.in-cards`,
`${CARD_GRID_HOST} > .canvasrefined-dashboard-notes.in-cards`,
`${CARD_GRID_HOST} > .canvasrefined-gpa-card:not(.container-mode)`,
].join(",");
function isCardGridCell(el) {
if (!el || !el.classList) return false;
if (el.classList.contains("ic-DashboardCard") || el.classList.contains("canvasrefined-gpa-card")) return true;
return el.classList.contains("in-cards")
&& (el.classList.contains("canvasrefined-widget-card") || el.classList.contains("canvasrefined-dashboard-notes"));
}
// Enabled widgets rendered before/after the cards, i.e. inside the grid.
function cardGridWidgets() {
return CR_SIMPLE_WIDGET_IDS.map(id => ({ id, position: crWidgetPosition(id) }))
.concat([{ id: "dashboard_notes", position: crWidgetPosition("dashboard_notes") }])
.filter(w => options[w.id] === true
&& (w.position === "cards_start" || w.position === "cards_end"));
}
function cardGridMetrics() {
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 colGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12);
const rowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12);
const gpaPos = gpaCalcPosition();
const gpaInGrid = options.gpa_calc === true && (gpaPos === "cards_start" || gpaPos === "cards_end");
const widgets = cardGridWidgets();
// Every widget and the GPA card need a cell, and the calculator a whole row.
// Nothing may rely on implicit rows: the template clips them to zero height.
const extraRows = Math.ceil((widgets.length + (gpaInGrid ? 1 : 0)) / cols) + (gpaInGrid ? 1 : 0);
return {
cols,
rows,
colGap,
rowGap,
widgets,
startWidgets: widgets.filter(w => w.position === "cards_start"),
gpaInGrid,
gpaFirst: gpaPos === "cards_start",
extraRows,
totalRows: rows + extraRows,
};
}
function applyAestheticChanges() { function applyAestheticChanges() {
if (quizSafeModeActive()) return; if (quizSafeModeActive()) return;
let style = document.querySelector("#canvasrefined-aesthetics") || document.createElement('style'); let style = document.querySelector("#canvasrefined-aesthetics") || document.createElement('style');
@ -9504,44 +9592,58 @@ function applyAestheticChanges() {
if (options.full_width === true) style.textContent += "#wrapper,.ic-Layout-wrapper{max-width:100%!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}"; 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: strict columns x rows layout, emitted after center_cards to win the // Card Grid: strict columns x rows layout, emitted after center_cards to win the
// !important tie-break. Columns are doubled into sub-columns (each card spans 2) so // !important tie-break. Columns are doubled into sub-columns (each cell spans 2) so
// "center uneven rows" can shift a partial row by half a card; two spare rows keep // "center uneven rows" can shift a partial row by half a card; spare rows keep the
// the GPA card/calculator out of the clipped implicit rows. // in-grid widgets (notes, timers, quotes, links, heatmap, GPA card/calculator) out
// of the clipped implicit rows.
if (options.card_grid === true) { if (options.card_grid === true) {
const gridCols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4); const grid = cardGridMetrics();
const gridRows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); const gridCols = grid.cols;
const gridColGap = Math.max(0, !isNaN(parseInt(options.card_grid_column_gap, 10)) ? parseInt(options.card_grid_column_gap, 10) : 12); const gridColGap = grid.colGap;
const gridRowGap = Math.max(0, !isNaN(parseInt(options.card_grid_row_gap, 10)) ? parseInt(options.card_grid_row_gap, 10) : 12); const gridRowGap = grid.rowGap;
// strip Canvas' natural top/left card margins; the grid supplies its own gaps
style.textContent += `.ic-DashboardCard__box__container{margin-top:0!important;margin-left:0!important}`;
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-top:0!important;margin-left:0!important}`;
// CanvasRefined's own widgets carry 36px outer margins meant for the flex
// card flow; in the grid every side is spacing the gaps already provide
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-widget-card,.ic-DashboardCard__box__container > .canvasrefined-dashboard-notes,.ic-DashboardCard__box__container > .canvasrefined-gpa-card,.ic-DashboardCard__box__container > .canvasrefined-gpa{margin:0!important}`;
const centerGrid = options.center_cards === true; const centerGrid = options.center_cards === true;
const gpaOn = options.gpa_calc === true;
const gpaPos = gpaCalcPosition();
const gpaInsideGrid = gpaPos === "cards_start" || gpaPos === "cards_end";
const gpaFirst = gpaPos === "cards_start";
const flexGrid = options.card_grid_flex === true; const flexGrid = options.card_grid_flex === true;
// content.css keeps this container a flex row whenever an in-card widget exists
// (so widgets line up with Canvas' loading skeletons). Those selectors carry more
// specificity than a bare class, so they are repeated here: grid mode has to
// out-specify that fallback instead of silently losing to it.
const gridHost = [
CARD_GRID_HOST,
`${CARD_GRID_HOST}:has(> .canvasrefined-widget-card.in-cards)`,
`${CARD_GRID_HOST}:has(> .canvasrefined-dashboard-notes.in-cards)`,
`${CARD_GRID_HOST}:has(> .canvasrefined-gpa-card:not(.container-mode))`,
].join(",");
// strip Canvas' natural top/left card margins; the grid supplies its own gaps
style.textContent += `${CARD_GRID_HOST}{margin-top:0!important;margin-left:0!important}`;
// CanvasRefined's own widgets carry 36px outer margins meant for the flex
// card flow (plus the card-style spacing); in the grid every side is spacing
// the gaps already provide
style.textContent += `${CARD_GRID_CELLS}{margin:0!important}`;
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{margin:0!important}`;
if (flexGrid) { if (flexGrid) {
// flexible grid: fixed-width tracks; @container queries step the column count // flexible grid: fixed-width tracks; @container queries step the column count
// down when the next column stops fitting (no window-width guessing) // down when the next column stops fitting (no window-width guessing)
const cardW = options.customCardStyles === true && parseInt(options.cardWidth, 10) > 0 // content-box card padding grows the cards past the set width, so the track
// (and the widget cards in it) has to follow
const cardPad = options.customCardStyles === true ? Math.max(0, parseInt(options.cardPadding, 10) || 0) : 0;
const cardW = (options.customCardStyles === true && parseInt(options.cardWidth, 10) > 0
? parseInt(options.cardWidth, 10) ? parseInt(options.cardWidth, 10)
: 262; : 262) + 2 * cardPad;
// container queries need a size container; the card wrapper is full-width // container queries need a size container; the card wrapper is full-width
style.textContent += `#DashboardCard_Container{container-type:inline-size}`; style.textContent += `#DashboardCard_Container{container-type:inline-size}`;
style.textContent += `.ic-DashboardCard__box__container{display:grid!important;grid-template-columns:repeat(${gridCols},${cardW}px)!important;grid-auto-rows:auto!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important${centerGrid ? ";justify-content:center!important" : ""}}`; style.textContent += `${gridHost}{display:grid!important;grid-template-columns:repeat(${gridCols},${cardW}px)!important;grid-auto-rows:auto!important;column-gap:${gridColGap}px!important;row-gap:${gridRowGap}px!important;align-items:start!important${centerGrid ? ";justify-content:center!important" : ""}}`;
// grid gaps alone control spacing (beats the later customCardStyles margins) // grid gaps alone control spacing (beats the later customCardStyles margins)
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-right:0!important;margin-bottom:0!important}`; style.textContent += `${CARD_GRID_CELLS}{margin-right:0!important;margin-bottom:0!important}`;
if (gpaOn && gpaInsideGrid) { if (grid.gpaInGrid) {
if (gpaFirst) { // Pin only while the GPA is alone in that slot: with another before-card
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1!important}`; // widget present, auto-placement in DOM order follows the saved widget
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`; // order, which the pin would override.
if (grid.gpaFirst && !grid.startWidgets.length) {
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1!important}`;
style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`;
} else { } else {
// auto-placed: flows into the next open slot after the last card // auto-placed: flows into the next open slot after the last card
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important}`; style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-column:1/-1!important}`;
} }
} }
// step down to m columns once m+1 no longer fit; widest-first so the // step down to m columns once m+1 no longer fit; widest-first so the
@ -9549,35 +9651,49 @@ function applyAestheticChanges() {
for (let m = gridCols - 1; m >= 1; m--) { for (let m = gridCols - 1; m >= 1; m--) {
const tooWideFor = (m + 1) * cardW + m * gridColGap; const tooWideFor = (m + 1) * cardW + m * gridColGap;
if (m === 1) { if (m === 1) {
style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:minmax(0,1fr)!important}.ic-DashboardCard__box__container > .ic-DashboardCard${gpaInsideGrid ? ",.ic-DashboardCard__box__container > .canvasrefined-gpa-card" : ""}{width:100%!important;min-width:0!important}}`; style.textContent += `@container (max-width:${tooWideFor - 1}px){${gridHost}{grid-template-columns:minmax(0,1fr)!important}${CARD_GRID_CELLS}{width:100%!important;min-width:0!important}}`;
} else { } else {
style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:repeat(${m},${cardW}px)!important}}`; style.textContent += `@container (max-width:${tooWideFor - 1}px){${gridHost}{grid-template-columns:repeat(${m},${cardW}px)!important}}`;
} }
} }
} else { } else {
const totalRows = gridRows + (gpaOn && gpaInsideGrid ? 2 : 0); style.textContent += `${gridHost}{display:grid!important;grid-template-columns:repeat(${gridCols * 2},minmax(0,max-content))!important;grid-template-rows:repeat(${grid.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{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" : ""}}`; // every cell-sized child spans one card (two sub-columns)
style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-column:span 2!important}`; style.textContent += `${CARD_GRID_CELLS}{grid-column:span 2!important}`;
if (gpaOn && gpaInsideGrid) { if (grid.gpaInGrid) {
// GPA card pinned to the first slot, or auto-placed after the cards; the // GPA card pinned to the first slot, or auto-placed after the cards; the
// expanded calculator always takes a full-width row // expanded calculator always takes a full-width row
if (gpaFirst) { if (grid.gpaFirst && !grid.startWidgets.length) {
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1/span 2!important}`; style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1/span 2!important}`;
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`; style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`;
} else { } else {
style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important}`; style.textContent += `${CARD_GRID_HOST} > .canvasrefined-gpa{grid-column:1/-1!important}`;
} }
} }
} }
} }
if (options.customCardStyles === true) { if (options.customCardStyles === true) {
// CanvasRefined's own widget cards are card faces too, so the card width, padding,
// roundness and spacing settings reach them as well. Only the faces that sit in
// the card flow are resized/spaced: a Top/Bottom row widget is sized by the
// Widgets tab, so it just inherits the face styling.
const widgetFace = ".canvasrefined-widget-card, .canvasrefined-dashboard-notes, .canvasrefined-gpa-card";
const widgetCard = ".canvasrefined-widget-card.in-cards, .canvasrefined-dashboard-notes.in-cards, .canvasrefined-gpa-card:not(.container-mode)";
const cardRoundness = parseInt(options.cardRoundness, 10);
const cardSpacing = parseInt(options.cardSpacing, 10);
const cardWidth = parseInt(options.cardWidth, 10);
const cardPadding = parseInt(options.cardPadding, 10);
if (options.imageSize !== undefined && options.imageSize !== 100) style.textContent += `.ic-DashboardCard__header_image {transform: scale(${options.imageSize / 100})!important; }`; 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;}`; if (options.cardRoundness !== undefined && options.cardRoundness !== 5) style.textContent += `.ic-DashboardCard {border-radius: ${options.cardRoundness}px!important;}`;
// widgets default to 4px, cards to 5px: follow the setting instead of the stock value
if (Number.isFinite(cardRoundness) && cardRoundness !== 5) style.textContent += `${widgetFace} {border-radius: ${cardRoundness}px!important;}`;
// the hero overlay sits on top of the photo, so both need the radius — rounding // the hero overlay sits on top of the photo, so both need the radius — rounding
// one alone leaves the other's square corners showing // one alone leaves the other's square corners showing
if (options.imageRoundness !== undefined && options.imageRoundness !== 0) style.textContent += `.ic-DashboardCard__header_image, .ic-DashboardCard__header_hero {border-radius: ${options.imageRoundness}px!important;}`; if (options.imageRoundness !== undefined && options.imageRoundness !== 0) style.textContent += `.ic-DashboardCard__header_image, .ic-DashboardCard__header_hero {border-radius: ${options.imageRoundness}px!important;}`;
if (options.cardSpacing !== undefined && options.cardSpacing !== 0) style.textContent += `.ic-DashboardCard {margin-right: ${options.cardSpacing / 2}px!important; margin-bottom: ${options.cardSpacing / 2}px!important;}`; if (options.cardSpacing !== undefined && options.cardSpacing !== 0) style.textContent += `.ic-DashboardCard {margin-right: ${options.cardSpacing / 2}px!important; margin-bottom: ${options.cardSpacing / 2}px!important;}`;
if (Number.isFinite(cardSpacing) && cardSpacing !== 0) style.textContent += `${widgetCard} {margin-right: ${cardSpacing / 2}px!important; margin-bottom: ${cardSpacing / 2}px!important;}`;
if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`; if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`;
if (Number.isFinite(cardWidth) && cardWidth > 0 && cardWidth !== 262) style.textContent += `${widgetCard} {width: ${cardWidth}px!important;}`;
// card height goes on the hero band (146px stock): the card and header are // card height goes on the hero band (146px stock): the card and header are
// content-sized, so a height on either just clips or adds dead space // content-sized, so a height on either just clips or adds dead space
if (options.condensed_cards !== true && options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") { if (options.condensed_cards !== true && options.cardHeight !== undefined && options.cardHeight !== null && options.cardHeight !== "") {
@ -9588,6 +9704,11 @@ function applyAestheticChanges() {
if (options.cardPadding !== undefined && Number(options.cardPadding) > 0) { if (options.cardPadding !== undefined && Number(options.cardPadding) > 0) {
style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important; box-sizing: content-box!important;}`; style.textContent += `.ic-DashboardCard {padding: ${options.cardPadding}px!important; box-sizing: content-box!important;}`;
} }
if (Number.isFinite(cardPadding) && cardPadding > 0) {
style.textContent += `${widgetFace} {padding: ${cardPadding}px!important; box-sizing: border-box!important;}`;
// fixed-width faces grow like Canvas cards; a row widget must stay inside its slot
style.textContent += `${widgetCard} {box-sizing: content-box!important;}`;
}
} }
// margins always cleared so the bar lines up with the content column; hiding it is opt-in // margins always cleared so the bar lines up with the content column; hiding it is opt-in
@ -9604,16 +9725,22 @@ Center uneven grid rows.
CSS grid always packs rows from the left, so a last row with fewer cards 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. 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 Pure CSS can't center a partial row, so we offset the first cell of the last
partial row with grid-column-start. partial row with grid-column-start.
The grid runs on double sub-columns (each card spans 2 — see The grid runs on double sub-columns (each cell spans 2 — see
applyAestheticChanges), which is what makes this possible at all: with 3 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 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 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 the shift is a whole sub-column (half a card + half a gap), so every
remainder can be centered exactly. remainder can be centered exactly.
In-card widgets are cells just like cards, so they count here too (a grid
filled by cards plus widgets has a partial last row all the same). Rows are
detected from the rendered boxes, not from a flat index: a full-width
calculator row (or a hidden card) leaves holes that index math would read as
the wrong row.
Only runs while Card Grid is on. Skips when the cards overflow the explicit 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 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 full). Any previously applied offset is cleared first so re-runs (card
@ -9622,14 +9749,14 @@ hidden/unhidden, dashboard re-render, setting changes) start clean.
function centerUnevenGridRows() { function centerUnevenGridRows() {
const container = document.querySelector(".ic-DashboardCard__box__container"); const container = document.querySelector(".ic-DashboardCard__box__container");
if (!container) return; if (!container) return;
// DOM order matches auto-placement order, so chunking this list matches the real rows // DOM order matches auto-placement order, so this list is in visual order
const items = Array.from(container.children).filter(el => el.classList && (el.classList.contains("ic-DashboardCard") || el.classList.contains("canvasrefined-gpa-card"))); const items = Array.from(container.children).filter(isCardGridCell);
items.forEach(el => el.style.removeProperty("grid-column")); items.forEach(el => el.style.removeProperty("grid-column"));
if (options.card_grid !== true || options.card_grid_center_rows !== true) return; if (options.card_grid !== true || options.card_grid_center_rows !== true) return;
// flexible grid changes the column count live, so fixed offsets would be wrong // flexible grid changes the column count live, so fixed offsets would be wrong
if (options.card_grid_flex === true) return; if (options.card_grid_flex === true) return;
const cols = Math.max(1, parseInt(options.card_grid_columns, 10) || 4); const grid = cardGridMetrics();
const rows = Math.max(1, parseInt(options.card_grid_rows, 10) || 3); const cols = grid.cols;
const visible = items.filter(el => getComputedStyle(el).display !== "none"); const visible = items.filter(el => getComputedStyle(el).display !== "none");
const count = visible.length; const count = visible.length;
// overflow past the template is clipped and the last visible row is full — nothing to center // overflow past the template is clipped and the last visible row is full — nothing to center
@ -9637,15 +9764,23 @@ function centerUnevenGridRows() {
const expanded = container.querySelector(":scope > .canvasrefined-gpa"); const expanded = container.querySelector(":scope > .canvasrefined-gpa");
return !!expanded && getComputedStyle(expanded).display !== "none"; return !!expanded && getComputedStyle(expanded).display !== "none";
})(); })();
const capacity = cols * (rows + (options.gpa_calc === true ? 2 : 0)) - (gpaExpanded ? cols : 0); const capacity = cols * grid.totalRows - (gpaExpanded ? cols : 0);
if (count === 0 || count > capacity) return; if (count === 0 || count > capacity) return;
const lastRowStart = Math.floor((count - 1) / cols) * cols; // Group by rendered row: cells of one row share a top edge.
const inLastRow = count - lastRowStart; const rows = [];
visible.forEach(el => {
const top = Math.round(el.getBoundingClientRect().top);
const row = rows.find(r => r.top === top);
if (row) row.els.push(el);
else rows.push({ top, els: [el] });
});
const lastRow = rows[rows.length - 1];
const inLastRow = lastRow ? lastRow.els.length : 0;
if (inLastRow >= cols) return; if (inLastRow >= cols) return;
// half the leftover, in sub-columns, paired with an explicit span; !important because // half the leftover, in sub-columns, paired with an explicit span; !important because
// the stylesheet span rule is !important too // the stylesheet span rule is !important too
const offset = cols - inLastRow; const offset = cols - inLastRow;
if (offset > 0) visible[lastRowStart].style.setProperty("grid-column", `${offset + 1} / span 2`, "important"); if (offset > 0) lastRow.els[0].style.setProperty("grid-column", `${offset + 1} / span 2`, "important");
} }
/* /*

View File

@ -31,6 +31,8 @@ const syncedSubOptions = [
"daily_quotes_position", "daily_quotes_position",
"quick_links_position", "quick_links_position",
"heatmap_position", "heatmap_position",
"widget_top_height",
"widget_bottom_height",
"widget_row_height", "widget_row_height",
"dashboard_notes_height", "dashboard_notes_height",
"gpa_calc_height", "gpa_calc_height",
@ -278,7 +280,10 @@ const defaultOptions = {
"quick_links_position": "cards_end", "quick_links_position": "cards_end",
"heatmap": false, "heatmap": false,
"heatmap_position": "below", "heatmap_position": "below",
// Height in px (0 = auto); widget_row_height is the shared Top/Bottom row. // Row height in px (0 = auto) for the Top and Bottom widget rows.
// widget_row_height is the pre-split shared value (legacy only).
"widget_top_height": 0,
"widget_bottom_height": 0,
"widget_row_height": 0, "widget_row_height": 0,
"dashboard_notes_height": 0, "dashboard_notes_height": 0,
"gpa_calc_height": 0, "gpa_calc_height": 0,
@ -405,6 +410,12 @@ chrome.storage.onChanged.addListener((changes) => {
} }
updateCardGridPreview(); updateCardGridPreview();
scheduleGridPreviewFollowUp(); scheduleGridPreviewFollowUp();
return;
}
// Widget toggles/positions decide which widgets take grid cells in the preview.
if (cardGridWidgetKeys().some(key => changes[key])) {
updateCardGridPreview();
scheduleGridPreviewFollowUp();
} }
}); });
@ -909,6 +920,40 @@ function countActiveDashboardCards(cards) {
return Object.values(cards || {}).filter(c => c && typeof c === "object" && !c.hidden && !c.hide).length; return Object.values(cards || {}).filter(c => c && typeof c === "object" && !c.hidden && !c.hide).length;
} }
// Card cells are blue, in-grid widgets yellow, empty slots grey.
const CARD_GRID_CARD_COLOR = "#56Caf0";
const CARD_GRID_WIDGET_COLOR = "#f5c451";
const CARD_GRID_EMPTY_COLOR = "#3c3c3c";
// Storage keys the preview needs to know which widgets land inside the grid.
function cardGridWidgetKeys() {
return WIDGET_LIST
.reduce((keys, w) => keys.concat([w.id, w.id + "_position"]), [])
.concat(["gpa_calc_prepend", "widget_order"]);
}
// Enabled widgets rendered before/after the cards, split by anchor. They take grid
// cells just like course cards (see content.js cardGridWidgets).
function cardGridWidgetCells(sync) {
const saved = Array.isArray(sync.widget_order && sync.widget_order.cards) ? sync.widget_order.cards : [];
const savedIds = saved.map(e => (typeof e === "string" ? e : (e && e.id) || null)).filter(Boolean);
const rank = id => {
const i = savedIds.indexOf(id);
return i === -1 ? savedIds.length : i;
};
const cells = { start: [], end: [] };
WIDGET_LIST.forEach(w => {
if (sync[w.id] !== true) return;
const pos = widgetPosFromSync(w.id, sync);
if (pos === "cards_start") cells.start.push(w.id);
else if (pos === "cards_end") cells.end.push(w.id);
});
// The saved order wins; WIDGET_LIST order breaks ties (sort is stable).
cells.start.sort((a, b) => rank(a) - rank(b));
cells.end.sort((a, b) => rank(a) - rank(b));
return cells;
}
function updateCardGridPreview() { function updateCardGridPreview() {
const panel = document.getElementById("card-grid-options"); const panel = document.getElementById("card-grid-options");
const preview = document.getElementById("card-grid-preview"); const preview = document.getElementById("card-grid-preview");
@ -917,33 +962,58 @@ function updateCardGridPreview() {
const rows = Math.max(1, parseInt(document.getElementById("card_grid_rows").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 colGapRaw = parseInt(document.getElementById("card_grid_column_gap").value, 10);
const colGap = Math.max(0, Math.min(10, isNaN(colGapRaw) ? 12 : colGapRaw)); const colGap = Math.max(0, Math.min(10, isNaN(colGapRaw) ? 12 : colGapRaw));
const total = cols * rows;
preview.style.gridTemplateColumns = `repeat(${cols}, 18px)`; preview.style.gridTemplateColumns = `repeat(${cols}, 18px)`;
// Clamped so extreme values don't blow the layout out. // Clamped so extreme values don't blow the layout out.
preview.style.columnGap = `${colGap}px`; preview.style.columnGap = `${colGap}px`;
preview.style.rowGap = "4px"; preview.style.rowGap = "4px";
preview.innerHTML = ""; preview.innerHTML = "";
chrome.storage.sync.get(["custom_cards", "gpa_calc"], async s => { chrome.storage.sync.get(["custom_cards", "card_grid_flex"].concat(cardGridWidgetKeys()), async s => {
if (document.getElementById("card-grid-options").style.display === "none") return; if (document.getElementById("card-grid-options").style.display === "none") return;
// Widgets in the cards area occupy cells too, so they count against the grid.
const widgetCells = cardGridWidgetCells(s);
const widgetIds = widgetCells.start.concat(widgetCells.end);
const gpaInGrid = widgetIds.includes("gpa_calc");
// Same row budget as content.js cardGridMetrics: nothing may rely on implicit
// rows (the grid clips those), so each in-grid widget gets rows of its own.
// The flexible grid grows rows on its own, so there it is the plain rows x cols.
const spareRows = s.card_grid_flex === true
? 0
: Math.ceil((widgetIds.filter(id => id !== "gpa_calc").length + (gpaInGrid ? 1 : 0)) / cols) + (gpaInGrid ? 1 : 0);
const total = cols * (rows + spareRows);
// Prefer the live count from a dashboard tab; it knows which cards are // Prefer the live count from a dashboard tab; it knows which cards are
// actually rendered. Falls back to the storage-derived count. // actually rendered. Falls back to the storage-derived count.
const live = await sendFromPopup("getGridCardCount"); const live = await sendFromPopup("getGridCardCount");
let active = (live && typeof live.count === "number" && live.count >= 0) const cardCount = (live && typeof live.count === "number" && live.count >= 0)
? live.count ? live.count
: countActiveDashboardCards(s.custom_cards); : countActiveDashboardCards(s.custom_cards);
if (s.gpa_calc === true) active += 1; // Auto-placement order: before-card widgets, the cards, after-card widgets.
const cells = widgetCells.start.map(id => ({ kind: "widget", id }))
.concat(Array.from({ length: cardCount }, () => ({ kind: "card" })),
widgetCells.end.map(id => ({ kind: "widget", id })));
const active = cells.length;
// The flexible grid grows rows on its own, so nothing is cut off there: draw
// every cell. The fixed grid clips, so it stays within its template.
const cellCount = s.card_grid_flex === true ? Math.max(total, active) : total;
let lastRowStart = -1, offset = 0; let lastRowStart = -1, offset = 0;
if (document.getElementById("card_grid_center_rows")?.checked === true && active > 0 && active < total) { if (document.getElementById("card_grid_center_rows")?.checked === true && active > 0 && active < total) {
lastRowStart = Math.floor((active - 1) / cols) * cols; lastRowStart = Math.floor((active - 1) / cols) * cols;
const inLastRow = active - lastRowStart; const inLastRow = active - lastRowStart;
if (inLastRow < cols) offset = cols - inLastRow; if (inLastRow < cols) offset = cols - inLastRow;
} }
for (let i = 0; i < total; i++) { for (let i = 0; i < cellCount; i++) {
const inShiftedRow = lastRowStart !== -1 && i >= lastRowStart; const inShiftedRow = lastRowStart !== -1 && i >= lastRowStart;
if (inShiftedRow && i >= active) continue; if (inShiftedRow && i >= active) continue;
const cell = cells[i];
const square = document.createElement("div"); const square = document.createElement("div");
square.style.cssText = "width:18px;height:18px;border-radius:4px;box-sizing:border-box;"; square.style.cssText = "width:18px;height:18px;border-radius:4px;box-sizing:border-box;";
square.style.background = i < active ? "#56Caf0" : "#3c3c3c"; if (!cell) {
square.style.background = CARD_GRID_EMPTY_COLOR;
} else if (cell.kind === "widget") {
square.style.background = CARD_GRID_WIDGET_COLOR;
square.title = widgetFullName(cell.id);
} else {
square.style.background = CARD_GRID_CARD_COLOR;
}
if (inShiftedRow && offset > 0) { if (inShiftedRow && offset > 0) {
square.style.marginLeft = `${offset * (18 + colGap) / 2}px`; square.style.marginLeft = `${offset * (18 + colGap) / 2}px`;
} }
@ -951,12 +1021,13 @@ function updateCardGridPreview() {
} }
const warning = document.getElementById("card-grid-warning"); const warning = document.getElementById("card-grid-warning");
if (warning) { if (warning) {
if (s.card_grid_flex === true) { // The flexible grid wraps instead of clipping, so only the fixed grid can
warning.textContent = getMsg("flexible_grid_on") || "Flexible grid is on - cards wrap to fit narrow windows instead of being cut off."; // overflow its template.
warning.style.display = "block"; if (s.card_grid_flex !== true && active > total) {
} else if (active > total) { const widgetNote = widgetIds.length
const gpaNote = s.gpa_calc === true ? getMsg("grid_includes_gpa", " (including the GPA calculator)") : ""; ? (getMsg("grid_includes_widgets", " (including {{NAMES}})")).replace("{{NAMES}}", widgetIds.map(widgetFullName).join(", "))
warning.textContent = "\u26A0 " + (getMsg("grid_warning") || "Your grid holds {{TOTAL}} card{{TOTAL_S}} but you have {{ACTIVE}} card{{ACTIVE_S}}{{GPA_NOTE}} - some cards may be cut off.").replace("{{TOTAL}}", total).replace("{{TOTAL_S}}", total === 1 ? "" : "s").replace("{{ACTIVE}}", active).replace("{{ACTIVE_S}}", active === 1 ? "" : "s").replace("{{GPA_NOTE}}", gpaNote); : "";
warning.textContent = "\u26A0 " + (getMsg("grid_warning") || "Your grid holds {{TOTAL}} card{{TOTAL_S}} but you have {{ACTIVE}} card{{ACTIVE_S}}{{GPA_NOTE}} - some cards may be cut off.").replace("{{TOTAL}}", total).replace("{{TOTAL_S}}", total === 1 ? "" : "s").replace("{{ACTIVE}}", active).replace("{{ACTIVE_S}}", active === 1 ? "" : "s").replace("{{GPA_NOTE}}", widgetNote);
warning.style.display = "block"; warning.style.display = "block";
} else { } else {
warning.style.display = "none"; warning.style.display = "none";
@ -1140,6 +1211,13 @@ function widgetDisplayName(id) {
return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
} }
// Unabbreviated name, for sentences (the preview warning lists widget names).
function widgetFullName(id) {
const info = WIDGET_LIST.find(w => w.id === id);
const msg = info && getMsg(info.nameKey);
return msg || widgetDisplayName(id);
}
let refreshWidgetOrder = null; let refreshWidgetOrder = null;
function setupWidgetOrder() { function setupWidgetOrder() {
@ -1338,14 +1416,25 @@ function setupWidgetPosition() {
}); });
} }
function setupWidgetHeightInput(id, initial) { // Height fields (px; empty = auto). `legacyKey` seeds an empty field from a key that
// has since been split in two (the Top/Bottom row height used to be shared), so users
// who set it keep their value until they edit the field.
function setupWidgetHeightInput(id, initial, legacyKey) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
const parse = (v) => { const parse = (v) => {
const n = parseInt(v, 10); const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0; return Number.isFinite(n) && n > 0 ? n : 0;
}; };
el.value = parse(initial) || ""; const apply = (v) => { el.value = parse(v) || ""; };
apply(initial);
if (legacyKey) {
chrome.storage.sync.get([id, legacyKey], sync => {
// A stored 0 means "auto" and must not be overwritten by the old value.
if (sync[id] !== undefined) { if (!parse(el.value)) apply(sync[id]); return; }
if (!parse(el.value)) apply(sync[legacyKey]);
});
}
el.addEventListener("change", function () { el.addEventListener("change", function () {
const n = Math.min(2000, Math.max(0, parseInt(this.value, 10) || 0)); const n = Math.min(2000, Math.max(0, parseInt(this.value, 10) || 0));
this.value = n > 0 ? n : ""; this.value = n > 0 ? n : "";
@ -1979,8 +2068,12 @@ async function setup() {
setup: (initial) => setupAnonProgressInput("anon_progress_total", initial, false), setup: (initial) => setupAnonProgressInput("anon_progress_total", initial, false),
}, },
{ {
identifier: "widget_row_height", identifier: "widget_top_height",
setup: (initial) => setupWidgetHeightInput("widget_row_height", initial), setup: (initial) => setupWidgetHeightInput("widget_top_height", initial, "widget_row_height"),
},
{
identifier: "widget_bottom_height",
setup: (initial) => setupWidgetHeightInput("widget_bottom_height", initial, "widget_row_height"),
}, },
{ {
identifier: "dashboard_notes_height", identifier: "dashboard_notes_height",