widget system done!

This commit is contained in:
Guy Sandler 2026-10-15 16:41:11 -07:00
parent 6268150cd6
commit 1ee82a68b0
6 changed files with 237 additions and 86 deletions

View File

@ -131,11 +131,14 @@
"widget_position": {
"message": "Position"
},
"widget_order_top": {
"message": "Top container order"
"widget_order_title": {
"message": "Widget order"
},
"widget_order_bottom": {
"message": "Bottom container order"
"widget_short_notes": {
"message": "Notes"
},
"widget_short_gpa": {
"message": "GPA"
},
"grade_settings": {
"message": "Grade Settings"

View File

@ -448,18 +448,20 @@
container-style card and the expanded calculator keeps full width. */
.canvasrefined-gpa-card.container-mode {width:100%;box-sizing:border-box;margin:0 0 4px;display:block;}
.canvasrefined-gpa.container-mode {margin:0 0 18px;}
/* Dashboard notes placed inside the card grid (before/after cards): shaped
like a course card (262px wide, rounded, Canvas card shadow) and kept at a
similar height so the grid row stays balanced. */
/* Dashboard notes placed inside the card grid (before/after cards): styled
exactly like the GPA card / Canvas course cards — 262px wide, rounded,
card shadow, and the same 36px gutters so it aligns in the grid. */
.canvasrefined-dashboard-notes.in-cards {
width: 262px;
min-height: 260px;
box-sizing: border-box;
margin: 0;
margin: 36px 0 0 36px;
border: none;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0,0,0,.3);
display: flex;
display: inline-flex;
flex-direction: column;
vertical-align: top;
}
.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-surface {flex: 1; display: flex; flex-direction: column;}
.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-editor,

View File

@ -381,11 +381,27 @@ input[type="checkbox"]:checked {background: #8dd28d;}
/* Widget container-order strips (Widgets tab): boxes sized by each widget's
% of the shared container, separated by draggable dividers. */
.widget-order-strip {display:flex;align-items:stretch;width:100%;height:64px;margin-top:8px;}
.widget-order-strip {display:flex;align-items:stretch;width:100%;height:44px;margin-top:6px;}
.wos-item {border:1px solid #ffffff2e;background:var(--inputbg);border-radius:7px;display:flex;align-items:center;padding:0 10px;font-size:12px;color:#e2e2e2;min-width:0;overflow:hidden;white-space:nowrap;cursor:grab;user-select:none;}
.wos-item.dragging {opacity:.45;}
.wos-divider {flex:0 0 6px;width:6px;align-self:stretch;margin:0 5px;border-radius:3px;background:#8a8a8a;cursor:col-resize;}
.wos-divider:hover {background:#e2e2e2;}
/* Widget order box (Widgets tab): one shared "Widget order" rectangle with
a section per area (Top / Before cards / After cards / Bottom); each
section shows only when 2+ enabled widgets share that area. */
.widget-order-section + .widget-order-section {margin-top: 16px;}
.widget-order-label {font-size: 12px;color: #c7c7c7;}
.widget-order-strip {margin-top: 6px;}
/* Before/after strips: sequence only, so boxes share the width evenly with
a gap between them instead of draggable dividers. */
.widget-order-strip[data-area="cards_start"],
.widget-order-strip[data-area="cards_end"] {column-gap: 8px;}
.widget-order-strip[data-area="cards_start"] .wos-item,
.widget-order-strip[data-area="cards_end"] .wos-item {flex: 1 1 0;}
/* Widget sections (Widgets tab): two side-by-side columns; add future
widgets as one more .option-container inside .widgets-grid. */
.widgets-grid {display:grid;grid-template-columns:1fr 1fr;gap:8px;align-items:stretch;margin-top:8px;}
.widgets-grid .option-container {margin-top:0;box-sizing:border-box;}
/* Widget position dropdowns (Widgets tab) — mirror #todo_timeframe exactly:
same font, padding, radius, colors and full-width stacked layout. */
.widget-pos {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;}

View File

@ -1113,18 +1113,29 @@
<h2 style="margin-top: 0" data-i18n="widgets">Widgets</h2>
<button class="big-button back-btn" data-i18n="back">Back</button>
</div>
<!-- Widget ordering: shown only when 2+ widgets share the top/bottom container -->
<!-- Widget ordering: one shared box titled "Widget order"; each area
section shows only when 2+ enabled widgets share that area -->
<div class="option-container" id="widget-order-container" style="display:none">
<div id="widget-order-top-wrap" style="display:none">
<h3 class="header-small" data-i18n="widget_order_top">Top container order</h3>
<h3 class="header-small" data-i18n="widget_order_title">Widget order</h3>
<div class="widget-order-section" id="widget-order-top-wrap" style="display:none">
<div class="widget-order-label" data-i18n="pos_top">Top</div>
<div class="widget-order-strip" id="widget-order-top"></div>
</div>
<div id="widget-order-below-wrap" style="display:none">
<h3 class="header-small" data-i18n="widget_order_bottom">Bottom container order</h3>
<div class="widget-order-section" id="widget-order-cards_start-wrap" style="display:none">
<div class="widget-order-label" data-i18n="pos_cards_start">Before cards</div>
<div class="widget-order-strip" id="widget-order-cards_start"></div>
</div>
<div class="widget-order-section" id="widget-order-cards_end-wrap" style="display:none">
<div class="widget-order-label" data-i18n="pos_cards_end">After cards</div>
<div class="widget-order-strip" id="widget-order-cards_end"></div>
</div>
<div class="widget-order-section" id="widget-order-below-wrap" style="display:none">
<div class="widget-order-label" data-i18n="pos_below">Bottom</div>
<div class="widget-order-strip" id="widget-order-below"></div>
</div>
</div>
<!-- Widgets: add one option-container per widget here -->
<!-- Widgets: two-column grid; add one option-container per widget here -->
<div class="widgets-grid">
<div class="option-container">
<div class="option" id="dashboard_notes">
<input type="radio" id="off" name="dashboard_notes">
@ -1177,6 +1188,7 @@
</div>
</div>
</div>
</div>
<div class="custom-font-container tab" style="display:none">
<!-- TODO: make this a dropdown -->

View File

@ -1031,6 +1031,7 @@ function applyOptionsChanges(changes) {
const row = document.querySelector(".canvasrefined-widget-row[data-area='" + area + "']");
if (row) applyWidgetRowLayout(row, area);
});
applyCardWidgetOrder(document.querySelector(".ic-DashboardCard__box__container"));
// A widget moved between areas (or joined/left a shared row) needs
// the OTHER widgets to re-place too: each widget only repositions
// itself on its own position key, so without this the widget left
@ -7825,11 +7826,13 @@ function placeGPAElements(dashboardContainer, container, container2) {
dashboardContainer.insertBefore(container, dashboardContainer.firstChild);
dashboardContainer.insertBefore(container2, container.nextSibling);
}
applyCardWidgetOrder(dashboardContainer);
} else if (pos === "cards_end") {
if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) {
dashboardContainer.appendChild(container2);
dashboardContainer.appendChild(container);
}
applyCardWidgetOrder(dashboardContainer);
} else if (pos === "top") {
if (container2.parentNode !== parent || container2.nextElementSibling !== container || container.nextElementSibling !== dashboardContainer) {
if (parent) {
@ -7868,6 +7871,72 @@ function containerWidgetOrder(area) {
}).filter(e => e && (e.id === "dashboard_notes" || e.id === "gpa_calc"));
}
// Card-grid order ("Before cards"/"After cards" positions): saved order of
// the card-shaped widgets living inside the card grid, used to decide which
// one comes first when several share the same anchor.
function cardWidgetOrder() {
const saved = options.widget_order && options.widget_order.cards;
if (!Array.isArray(saved)) return [];
return saved.map(e => {
if (typeof e === "string") return e;
if (e && typeof e === "object" && e.id) return e.id;
return null;
}).filter(id => id === "dashboard_notes" || id === "gpa_calc");
}
// Normalize the relative order of card-shaped widgets inside the card grid.
// Each widget keeps its own anchor (before vs. after the course cards);
// widgets sharing an anchor are sequenced by the saved card order. Only
// re-inserts when the DOM order is actually wrong, so idle passes don't ping
// the dashboard mutation observer into another pass.
function applyCardWidgetOrder(grid) {
if (!grid) return;
const saved = cardWidgetOrder();
const units = [];
const notes = grid.querySelector(":scope > .canvasrefined-dashboard-notes");
if (notes && notes.classList.contains("in-cards")) {
units.push({
id: "dashboard_notes",
anchor: options.dashboard_notes_position === "cards_start" ? "start" : "end",
els: [notes]
});
}
const gpaCard = grid.querySelector(":scope > .canvasrefined-gpa-card");
if (gpaCard && !gpaCard.classList.contains("container-mode")) {
const pos = gpaCalcPosition();
const anchor = pos === "cards_start" ? "start" : "end";
// The GPA renders as two adjacent direct children (hidden calculator
// + visible card). Keep the pair in its natural placement order so
// the check below doesn't fight the placement code.
const calc = grid.querySelector(":scope > .canvasrefined-gpa");
const els = calc ? (anchor === "start" ? [calc, gpaCard] : [gpaCard, calc]) : [gpaCard];
units.push({ id: "gpa_calc", anchor, els });
}
if (units.length < 2) return;
const rank = id => {
const i = saved.indexOf(id);
return i === -1 ? saved.length : i;
};
["start", "end"].forEach(anchor => {
const group = units.filter(u => u.anchor === anchor)
.sort((a, b) => rank(a.id) - rank(b.id));
if (group.length < 2) return;
const desired = group.flatMap(u => u.els);
const positions = desired.map(el => [...grid.children].indexOf(el));
const sorted = [...positions].sort((a, b) => a - b);
const inOrder = desired.every((_, i) =>
anchor === "start" ? positions[i] === sorted[i] : positions[i] === sorted[sorted.length - 1 - i]);
if (inOrder) return;
if (anchor === "start") {
// Insert in reverse so the first widget of the group ends up
// leftmost, right before the course cards.
for (let i = desired.length - 1; i >= 0; i--) grid.insertBefore(desired[i], grid.firstChild);
} else {
desired.forEach(el => grid.appendChild(el));
}
});
}
// Both widgets must share one row: normalize the anchor to the outer
// DashboardCard_Container. The GPA's dashboardContainer can be the inner
// .ic-DashboardCard__box__container, whose parent is INSIDE the card
@ -8493,24 +8562,41 @@ function wireDashboardNotes(notes) {
// Mount the notes at the position chosen in the Widgets tab:
// top — full-width container above the card grid
// cards_start / cards_end — card-shaped cell inside the grid, before/after cards
// cards_start / cards_end — card-shaped cell inside the card grid (same
// grid the GPA card uses), before/after cards
// below — full-width container below the grid, with some margin
function placeDashboardNotes(notes, container) {
const pos = options.dashboard_notes_position || "top";
if (notes.dataset.crPos === pos) return;
notes.classList.toggle("in-cards", pos === "cards_start" || pos === "cards_end");
const parent = container.parentNode;
if (pos === "top" || pos === "below") {
notes.classList.remove("in-cards");
const slot = notes.closest && notes.closest(".canvasrefined-widget-slot");
const row = slot && slot.parentNode;
// Already in the right shared row? Leave it alone.
if (notes.dataset.crPos === pos && row && row.dataset.area === pos && slot.dataset.widget === "dashboard_notes") return;
// Full-width container above/below the cards; shares the row with any
// other container-mode widget, widths from the Widgets tab.
placeWidgetInRow("dashboard_notes", pos, [notes], container);
} else {
notes.classList.add("in-cards");
// Same container the GPA card uses for before/after-cards placement,
// resolved here (not at call sites) so every entry path lands in the
// real card grid.
const grid = document.querySelector(".ic-DashboardCard__box__container") || container;
if (notes.dataset.crPos === pos && notes.parentNode === grid) return;
releaseWidgetFromRow("dashboard_notes", [notes], container);
if (pos === "cards_start") {
container.prepend(notes);
grid.prepend(notes);
} else {
container.appendChild(notes);
grid.appendChild(notes);
}
applyCardWidgetOrder(grid);
// The card grid is Canvas's jQuery sortable list; refresh it so our
// injected cell is recognized when cards are drag-reordered.
try {
if (grid !== container && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
window.jQuery(grid).sortable('refresh');
}
} catch (e) {}
}
notes.dataset.crPos = pos;
}

View File

@ -1005,8 +1005,8 @@ function toggleAlternateColorsVisibility(darkModeOn) {
// Widgets tab: position dropdowns for widgets that support placement.
const WIDGET_LIST = [
{ id: "dashboard_notes", nameKey: "dashboard_notes" },
{ id: "gpa_calc", nameKey: "gpa_calculator" },
{ id: "dashboard_notes", nameKey: "dashboard_notes", shortKey: "widget_short_notes" },
{ id: "gpa_calc", nameKey: "gpa_calculator", shortKey: "widget_short_gpa" },
];
function widgetPosFromSync(id, sync) {
@ -1037,37 +1037,50 @@ function parseWidgetOrderEntries(raw) {
function widgetDisplayName(id) {
const info = WIDGET_LIST.find(w => w.id === id);
return (info && chrome.i18n.getMessage(info.nameKey)) || id;
return (info && chrome.i18n.getMessage(info.shortKey || info.nameKey)) || id;
}
// Live-refresh hook for the widget-order UI, set by setupWidgetOrder and
// called directly after the popup itself changes a widget's position or
// enabled state — so the order box updates instantly without waiting for
// the storage.onChanged round-trip.
let refreshWidgetOrder = null;
function setupWidgetOrder() {
const wrap = document.getElementById("widget-order-container");
if (!wrap) return;
const KEYS = ["dashboard_notes", "gpa_calc", "dashboard_notes_position", "gpa_calc_position", "gpa_calc_prepend", "widget_order"];
const AREAS = ["top", "cards_start", "cards_end", "below"];
const render = (sync) => {
const areas = { top: [], below: [] };
const areas = { top: [], cards_start: [], cards_end: [], below: [] };
WIDGET_LIST.forEach(w => {
if (sync[w.id] !== true) return;
const pos = widgetPosFromSync(w.id, sync);
if (pos === "top" || pos === "below") areas[pos].push(w.id);
if (areas[pos]) areas[pos].push(w.id);
});
let any = false;
["top", "below"].forEach(area => {
AREAS.forEach(area => {
const strip = document.getElementById("widget-order-" + area);
const wrapEl = document.getElementById("widget-order-" + area + "-wrap");
if (!strip || !wrapEl) return;
strip.dataset.area = area;
const ids = areas[area];
// A section only makes sense once 2+ widgets share the same
// position — with a single widget there is nothing to order.
if (ids.length < 2) {
wrapEl.style.display = "none";
return;
}
any = true;
wrapEl.style.display = "";
// Saved entries first (order + widths), then any newcomers.
const entries = parseWidgetOrderEntries((sync.widget_order || {})[area]).filter(e => ids.includes(e.id));
// Saved entries first (order + widths), then any newcomers. The
// before/after groups share the single "cards" saved order; each
// strip only shows the widgets actually in its group.
const saved = (sync.widget_order || {})[area.startsWith("cards") ? "cards" : area];
const entries = parseWidgetOrderEntries(saved).filter(e => ids.includes(e.id));
ids.forEach(id => { if (!entries.some(e => e.id === id)) entries.push({ id, size: null }); });
renderWidgetOrderStrip(strip, entries);
});
@ -1075,7 +1088,7 @@ function setupWidgetOrder() {
};
// Reordering: drag a box horizontally past a neighbor.
["top", "below"].forEach(area => {
AREAS.forEach(area => {
const strip = document.getElementById("widget-order-" + area);
if (!strip) return;
strip.addEventListener("dragover", e => {
@ -1094,6 +1107,7 @@ function setupWidgetOrder() {
});
const refresh = () => chrome.storage.sync.get(KEYS, render);
refreshWidgetOrder = refresh;
refresh();
chrome.storage.onChanged.addListener((changes, area) => {
@ -1104,8 +1118,10 @@ function setupWidgetOrder() {
function renderWidgetOrderStrip(strip, entries) {
strip.textContent = "";
// Before/after strips are sequence only — no width resizing, no dividers.
const resizable = !strip.dataset.area.startsWith("cards");
entries.forEach((entry, i) => {
if (i > 0) {
if (resizable && i > 0) {
const divider = document.createElement("div");
divider.className = "wos-divider";
wireWidgetOrderDivider(strip, divider);
@ -1114,8 +1130,10 @@ function renderWidgetOrderStrip(strip, entries) {
const item = document.createElement("div");
item.className = "wos-item";
item.dataset.id = entry.id;
if (resizable) {
item.dataset.size = entry.size == null ? (100 / entries.length) : entry.size;
item.style.flexBasis = item.dataset.size + "%";
}
item.draggable = true;
item.textContent = widgetDisplayName(entry.id);
item.addEventListener("dragstart", e => {
@ -1177,15 +1195,26 @@ function wireWidgetOrderDivider(strip, divider) {
function saveWidgetStrip(area, strip) {
const items = [...strip.querySelectorAll(".wos-item")];
if (items.length < 2) return;
const order = items.map(el => ({ id: el.dataset.id }));
// Before/after strips both save into the shared "cards" order, merged
// with the entries of the other group so nothing is lost.
const storageArea = area.startsWith("cards") ? "cards" : area;
chrome.storage.sync.get(["widget_order"], sync => {
const wo = Object.assign({}, sync.widget_order || {});
if (storageArea === "cards") {
const ids = new Set(order.map(e => e.id));
const other = (sync.widget_order && Array.isArray(sync.widget_order.cards) ? sync.widget_order.cards : [])
.filter(e => e && !ids.has(e.id));
wo.cards = other.concat(order);
} else {
// Container rows still carry each widget's % width.
let sizes = items.map(el => parseFloat(el.dataset.size) || 100 / items.length);
const total = sizes.reduce((a, b) => a + b, 0) || 100;
sizes = sizes.map(v => v / total * 100);
const rounded = sizes.map(v => Math.round(v));
rounded[rounded.length - 1] += 100 - rounded.reduce((a, b) => a + b, 0);
const order = items.map((el, i) => ({ id: el.dataset.id, size: rounded[i] }));
chrome.storage.sync.get(["widget_order"], sync => {
const wo = Object.assign({}, sync.widget_order || {});
wo[area] = order;
wo[storageArea] = items.map((el, i) => ({ id: el.dataset.id, size: rounded[i] }));
}
chrome.storage.sync.set({ widget_order: wo });
});
}
@ -1859,6 +1888,9 @@ function setup() {
chrome.storage.sync.set({ "hide_course_images": false });
}
toggleSubOptionsVisibility(option, status);
if (option === "dashboard_notes" || option === "gpa_calc") {
if (refreshWidgetOrder) refreshWidgetOrder();
}
});
});
toggleBetterSidebarSubOptions(sync["better_sidebar"] === true);