diff --git a/README.md b/README.md index 89531c4..cd5a7cd 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ If nobody is working on the feature, you can clone the dev branch and make your #### Fork Contributers - [TabulateJarl8](https://github.com/TabulateJarl8) +- [dhogenson](https://github.com/dhogenson) #### Original Owner diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b72bcab..b40a7ca 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -125,6 +125,33 @@ "gpa_calculator": { "message": "GPA Calculator" }, + "widgets": { + "message": "Widgets" + }, + "widget_position": { + "message": "Position" + }, + "widget_order_top": { + "message": "Top container order" + }, + "widget_order_bottom": { + "message": "Bottom container order" + }, + "grade_settings": { + "message": "Grade Settings" + }, + "pos_top": { + "message": "Top" + }, + "pos_cards_start": { + "message": "Before cards" + }, + "pos_cards_end": { + "message": "After cards" + }, + "pos_below": { + "message": "Bottom" + }, "gpa_settings": { "message": "GPA Settings" }, @@ -164,9 +191,6 @@ "themes": { "message": "Themes" }, - "move_top": { - "message": "Move To Top" - }, "show_past_due": { "message": "Show Past Due" }, diff --git a/css/content.css b/css/content.css index 78d2af7..e78001c 100644 --- a/css/content.css +++ b/css/content.css @@ -429,3 +429,120 @@ .css-fkmnqn-view { margin-bottom: 0 !important; } +/* Shared widget container ("Top"/"Bottom" positions): a full-width flex row + holding one slot per widget; slot widths are the % set in the Widgets tab. + Row margins mirror the notes container's original 18/14 spacing. */ +.canvasrefined-widget-row {display:flex;align-items:stretch;width:100%;box-sizing:border-box;margin:18px 0 14px;column-gap:14px;} +.canvasrefined-widget-slot {min-width:0;box-sizing:border-box;display:flex;flex-direction:column;} +.canvasrefined-widget-slot > * {width:100%;} +/* Equal height across widgets in the shared container: every slot stretches + to the row height and its widget content fills it top-to-bottom. */ +.canvasrefined-widget-slot .canvasrefined-dashboard-notes {width:100%;margin:0;height:100%;display:flex;flex-direction:column;} +.canvasrefined-widget-slot .canvasrefined-dashboard-notes .canvasrefined-notes-surface {flex:1;display:flex;flex-direction:column;} +.canvasrefined-widget-slot .canvasrefined-dashboard-notes .canvasrefined-notes-editor, +.canvasrefined-widget-slot .canvasrefined-dashboard-notes .canvasrefined-notes-rendered {flex:1;overflow:auto;} +.canvasrefined-widget-slot .canvasrefined-gpa-card {width:100%;margin:0;box-sizing:border-box;display:block;flex:1 1 auto;min-height:0;} +.canvasrefined-widget-slot .canvasrefined-gpa {margin:0;flex:1 1 auto;min-height:0;overflow:auto;} +/* GPA calculator in container mode ("Top"/"Bottom" positions): the elements + are moved outside the card grid, so the collapsed GPA card becomes a wide + 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. */ +.canvasrefined-dashboard-notes.in-cards { + width: 262px; + min-height: 260px; + box-sizing: border-box; + margin: 0; + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0,0,0,.3); + display: flex; + flex-direction: column; +} +.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-surface {flex: 1; display: flex; flex-direction: column;} +.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-editor, +.canvasrefined-dashboard-notes.in-cards .canvasrefined-notes-rendered {flex: 1; resize: none; border: none; outline: none; background: transparent;} +/* Notes placed in the shared widget row get their margins from the row + (see .canvasrefined-widget-slot rules above). */ +/* GPA calculator modal (Edit Calculator button): combines the course + calculator with the grade-scale editor, styled like the global search + modal. */ +/* ===== GPA calculator modal (rebuilt) ===== */ +#canvasrefined-gpa-modal { + --cr-gpa-course-cols: 30px minmax(0,1fr) 66px 58px 118px 16px minmax(88px,auto); + --cr-gpa-accent: var(--ic-brand-primary,#0374b5); + position:fixed;inset:0;z-index:2147483100;background:rgba(15,25,40,.5);backdrop-filter:blur(3px); + display:flex;align-items:flex-start;justify-content:center;padding:7vh 16px 16px; +} +.cr-gpa-modal {width:min(1080px,96vw);max-height:86vh;display:flex;flex-direction:column;background:var(--bcbackground-0,#fff);color:var(--bctext-0,#2d3b45);border:1px solid var(--bcborders,#c7cdd1);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.4);overflow:hidden;font-size:13px;} +/* Header: title on the left, live GPA tiles on the right */ +.cr-gpa-head {display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid var(--bcborders,#c7cdd1);} +.cr-gpa-head-titles {display:flex;flex-direction:column;gap:2px;margin-right:auto;min-width:140px;} +.cr-gpa-title {font-size:17px;font-weight:700;} +.cr-gpa-subtitle {font-size:12px;color:var(--bctext-2,#9aa3ab);} +.cr-gpa-results {display:flex;gap:10px;align-items:stretch;} +.cr-gpa-result {display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;min-width:76px;padding:6px 12px;border-radius:12px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 6%, transparent);} +.cr-gpa-result-value {font-size:20px;font-weight:700;line-height:1.15;font-variant-numeric:tabular-nums;} +.cr-gpa-result-label {font-size:10px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);} +.cr-gpa-close {display:flex;align-items:center;justify-content:center;width:32px;height:32px;flex:none;border:none;background:transparent;color:inherit;border-radius:8px;cursor:pointer;} +.cr-gpa-close:hover {background:rgba(127,127,127,.15);} +/* Body: courses on the left, grade scale on the right; panes scroll alone */ +.cr-gpa-body {display:grid;grid-template-columns:minmax(0,2.2fr) minmax(210px,1fr);flex:1;min-height:0;} +.cr-gpa-pane {min-width:0;overflow-y:auto;padding:14px 20px 20px;} +.cr-gpa-pane-scale {border-left:1px solid var(--bcborders,#c7cdd1);padding-left:14px;} +.cr-gpa-pane-head {font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);margin:0 0 10px;} +/* Calculator body moved into the modal: neutralize panel chrome */ +.cr-gpa-modal .canvasrefined-gpa {display:block!important;margin:0;padding:0;border:none;box-shadow:none!important;overflow:visible;position:static;background:transparent!important;border-radius:0;} +.cr-gpa-modal .canvasrefined-gpa .canvasrefined-gpa-header {display:none;} +.cr-gpa-modal .canvasrefined-gpa-courses-container {margin:0;width:100%;overflow:visible;} +.cr-gpa-modal .canvasrefined-gpa-courses {display:flex;flex-direction:column;min-width:0;width:100%;} +/* Course rows: shared grid so the column headers line up with the rows */ +.cr-gpa-colhead, +.cr-gpa-modal .canvasrefined-gpa-course, +.cr-gpa-modal #canvasrefined-cumulative-gpa {display:grid;grid-template-columns:var(--cr-gpa-course-cols);column-gap:10px;align-items:center;} +.cr-gpa-colhead {padding:0 8px 6px;font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);} +.cr-gpa-colhead .cr-gpa-colhead-custom {grid-column:6 / span 2;} +.cr-gpa-modal .canvasrefined-gpa-course {padding:7px 8px;border-radius:10px;} +.cr-gpa-modal .canvasrefined-gpa-course:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 5%, transparent);} +/* Letter badge: color-coded pill driven by data-grade-letter (set in calculateGPA2) */ +.cr-gpa-modal .canvasrefined-gpa-letter-grade {width:auto;margin:0;display:flex;align-items:center;justify-content:center;min-height:26px;padding:0 2px;border-radius:8px;font-size:12px;font-weight:700;background:transparent;color:var(--bctext-0,#2d3b45);} +.cr-gpa-modal [data-grade-letter] .canvasrefined-gpa-letter-grade {background:transparent;color:var(--bctext-0,#2d3b45);} +.cr-gpa-modal .canvasrefined-gpa-name {margin:0;width:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:500;} +.cr-gpa-modal .canvasrefined-gpa-percent-container, +.cr-gpa-modal .canvasrefined-course-credits {margin:0;display:flex;align-items:center;gap:4px;} +.cr-gpa-modal .canvasrefined-course-percent, +.cr-gpa-modal .canvasrefined-course-credit {height:30px;width:100%;font-size:14px;border-radius:8px;padding:0 8px;box-sizing:border-box;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);text-align:right;} +.cr-gpa-modal .canvasrefined-course-percent-sign {color:var(--bctext-2,#9aa3ab);font-size:12px;} +.cr-gpa-modal .canvasrefined-course-weights {margin:0;} +.cr-gpa-modal .canvasrefined-course-weight {height:30px;width:100%;min-width:0;margin:0;font-size:13px;border-radius:8px;padding:0 6px;box-sizing:border-box;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-0,#2d3b45);} +.cr-gpa-modal .canvasrefined-course-percent:focus, +.cr-gpa-modal .canvasrefined-course-credit:focus, +.cr-gpa-modal .canvasrefined-course-weight:focus {outline:none;border-color:var(--cr-gpa-accent);} +.cr-gpa-modal .canvasrefined-course-customgr {margin:0;width:16px;height:16px;accent-color:var(--cr-gpa-accent);cursor:pointer;} +.cr-gpa-modal .canvasrefined-course-customgr-label {margin:0;font-size:12px;color:var(--bctext-2,#9aa3ab);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +/* Cumulative row: pulled to the bottom as an emphasized summary row */ +.cr-gpa-modal #canvasrefined-cumulative-gpa {order:100;margin-top:10px;padding:12px 8px 4px;border-top:1px dashed var(--bcborders,#c7cdd1);} +.cr-gpa-modal #canvasrefined-cumulative-gpa .canvasrefined-gpa-name {font-weight:700;} +/* Grade scale pane */ +.cr-gpa-seg {display:flex;gap:8px;padding:0;background:transparent;margin:0 0 14px;} +.cr-gpa-preset {flex:1;border:1px solid var(--bcborders,#c7cdd1);background:transparent;color:var(--bctext-2,#9aa3ab);border-radius:8px;padding:7px 10px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;} +.cr-gpa-preset:hover {color:var(--bctext-0,#2d3b45);} +.cr-gpa-preset.active {background:var(--bcbackground-0,#fff);color:var(--bctext-0,#2d3b45);border-color:var(--bctext-2,#9aa3ab);} +/* Grade scale rows: letter chip + compact "value + unit" fields */ +.cr-gpa-scale-head {display:grid;grid-template-columns:40px 1fr 1fr;gap:8px;padding:0 2px 4px;font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--bctext-2,#9aa3ab);} +.cr-gpa-row {display:grid;grid-template-columns:40px 1fr 1fr;gap:8px;align-items:center;padding:2px 0;} +.cr-gpa-row:hover {background:color-mix(in srgb, var(--bctext-0,#2d3b45) 4%, transparent);border-radius:8px;} +.cr-gpa-chip {display:flex;align-items:center;justify-content:center;height:24px;border-radius:7px;background:color-mix(in srgb, var(--bctext-0,#2d3b45) 7%, transparent);font-weight:700;font-size:12px;} +.cr-gpa-field {display:flex;align-items:center;min-width:0;border:1px solid var(--bcborders,#c7cdd1);border-radius:8px;height:28px;background:transparent;overflow:hidden;} +.cr-gpa-field:focus-within {border-color:var(--cr-gpa-accent,var(--ic-brand-primary,#0374b5));box-shadow:0 0 0 2px color-mix(in srgb, var(--cr-gpa-accent,var(--ic-brand-primary,#0374b5)) 22%, transparent);} +.cr-gpa-input {width:100%;min-width:0;border:none;background:transparent;color:inherit;height:100%;padding:0 4px 0 8px;font:inherit;font-size:13px;text-align:right;box-sizing:border-box;} +.cr-gpa-input:focus {outline:none;box-shadow:none;} +.cr-gpa-suffix {flex:none;padding:0 8px 0 2px;font-size:11px;color:var(--bctext-2,#9aa3ab);} +.cr-gpa-note {color:var(--bctext-2,#9aa3ab);font-size:12px;line-height:1.5;margin:14px 0 0;padding-top:12px;border-top:1px solid var(--bcborders,#c7cdd1);} +@media (max-width:720px) { + #canvasrefined-gpa-modal {--cr-gpa-course-cols:26px minmax(0,1fr) 58px 52px 108px 15px 88px;padding:4vh 8px 8px;} + .cr-gpa-body {grid-template-columns:1fr;} + .cr-gpa-pane-scale {border-left:none;border-top:1px solid var(--bcborders,#c7cdd1);} +} diff --git a/css/popup.css b/css/popup.css index 0fbb13b..c83105f 100644 --- a/css/popup.css +++ b/css/popup.css @@ -122,7 +122,7 @@ h2 {margin-top: 20px;font-weight:600} .todo-more-options.expanded .todo-expanded-label {display:inline;} .todo-more-items {display:none;flex-direction:column;} .todo-more-options.expanded .todo-more-items {display:flex;padding-left:2px;} -#advanced-settings, #gpa-bounds-btn, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;} +#advanced-settings, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;} .back-btn {width: 120px;height:100%;margin-top:0} .big-button {border: none;border-radius: 7px;background: var(--containerbg);color: #fff;font-family: inherit;padding: 12px 10px;font-weight:600} .social-buttons {display: flex;gap: 8px;margin-top: 8px;} @@ -138,9 +138,6 @@ h2 {margin-top: 20px;font-weight:600} .social-toggle:hover {color: #e2e2e2} .social-buttons.hidden {display: none} #setSingleColor, #setGradientColor {padding: 8px 20px;margin-top:5px;} -.gpa-bounds-input {font-size: 14px;background: var(--inputbg); border: none;padding:6px;color: #e2e2e2;margin-top: 6px;border-radius: 6px; font-family: inherit; width: 55px; box-sizing: border-box;text-align: right} -.gpa-bounds span {font-size: 16px; width: 25px;display: inline-block} -.gpa-bounds-explanation {width:200px;height:100%;flex: 1;} .tab-header {display: flex;justify-content: space-between;gap:20px;align-items:center} .custom-font-flex {display: flex; align-items: center;font-size: 11px;} #custom-font-family, #custom-font-link {width:150px} @@ -381,3 +378,17 @@ input[type="checkbox"]:checked {background: #8dd28d;} } .cr-info-bubble:hover .cr-info-bubble-text, .cr-info-bubble:focus .cr-info-bubble-text { visibility: visible; opacity: 1; } + +/* 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;} +.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 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;} +.widget-pos option {background:#1e1e1e;color:#e2e2e2;} +.widget-pos option:checked, +.widget-pos option:hover {background:#2e2e2e;color:#fff;} diff --git a/html/popup.html b/html/popup.html index 02b1377..dff1aab 100644 --- a/html/popup.html +++ b/html/popup.html @@ -37,7 +37,7 @@ Edit Dark Mode + + +
+
+ + +
+
+
+
Dashboard Notes +
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+ GPA calculator +
+
+
+ + +
+
+ + +
+
+ + +
-
diff --git a/js/background.js b/js/background.js index f039984..bd64175 100644 --- a/js/background.js +++ b/js/background.js @@ -40,6 +40,8 @@ chrome.runtime.onInstalled.addListener(function () { "dashboard_notes": false, "dashboard_notes_text": "", "dashboard_notes_mode": "edit", + "dashboard_notes_position": "top", + "widget_order": {}, "better_todo": true, "todo_hr24": false, "todo_separate_scrollbar": false, diff --git a/js/content.js b/js/content.js index 39f272d..1eaf893 100644 --- a/js/content.js +++ b/js/content.js @@ -1023,8 +1023,21 @@ function applyOptionsChanges(changes) { case "dashboard_notes": case "dashboard_notes_text": case "dashboard_notes_mode": + case "dashboard_notes_position": loadDashboardNotes(); break; + case "widget_order": + ["top", "below"].forEach(area => { + const row = document.querySelector(".canvasrefined-widget-row[data-area='" + area + "']"); + if (row) applyWidgetRowLayout(row, area); + }); + // 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 + // behind would keep the old layout until reload. + if (options.dashboard_notes === true) loadDashboardNotes(); + if (options.gpa_calc === true && current_page === "/") setupGPACalc(); + break; case "dashboard_grades": case "grade_hover": case "card_letter": @@ -1144,6 +1157,7 @@ function applyOptionsChanges(changes) { break; case "gpa_calc": case "gpa_calc_prepend": + case "gpa_calc_position": case "gpa_calc_weighted": case "gpa_calc_cumulative": if (!grades) getGrades(); @@ -1155,6 +1169,10 @@ function applyOptionsChanges(changes) { break; case "gpa_calc_bounds": calculateGPA2(); + { + const modal = document.getElementById("canvasrefined-gpa-modal"); + if (modal) renderGpaBoundsEditor(modal.querySelector(".cr-gpa-modal-bounds")); + } break; case "custom_font": case "custom_font_skip_p": @@ -7598,6 +7616,7 @@ GPA calculator function calculateGPA2() { let qualityPoints = 0, numCredits = 0, weightedQualityPoints = 0, cumulativePoints = 0, cumulativeCredits = 0; document.querySelectorAll('.canvasrefined-gpa-course').forEach(course => { + course.dataset.gradeLetter = "--"; const weight = course.querySelector('.canvasrefined-course-weight').value; const credits = parseFloat(course.querySelector('.canvasrefined-course-credit').value); const grade = parseFloat(course.querySelector('.canvasrefined-course-percent').value); @@ -7645,6 +7664,7 @@ function calculateGPA2() { gpa = options.gpa_calc_bounds["F"].gpa; } course.querySelector(".canvasrefined-gpa-letter-grade").textContent = letter; + course.dataset.gradeLetter = letter; let weightMultiplier = 0; if (weight === "ap") { @@ -7659,12 +7679,19 @@ function calculateGPA2() { }); - document.querySelector("#canvasrefined-gpa-unweighted").textContent = (qualityPoints / numCredits).toFixed(2); - document.querySelector("#canvasrefined-gpa-weighted").textContent = (weightedQualityPoints / numCredits).toFixed(2); + document.querySelector("#canvasrefined-gpa-unweighted").textContent = Number.isFinite(qualityPoints / numCredits) ? (qualityPoints / numCredits).toFixed(2) : "--"; + document.querySelector("#canvasrefined-gpa-weighted").textContent = Number.isFinite(weightedQualityPoints / numCredits) ? (weightedQualityPoints / numCredits).toFixed(2) : "--"; const cGPA = document.querySelector("#canvasrefined-cumulative-gpa"); const g = parseFloat(cGPA.querySelector(".canvasrefined-course-percent").value); const c = parseInt(cGPA.querySelector(".canvasrefined-course-credit").value); - document.querySelector("#canvasrefined-gpa-cumulative").textContent = (((options.gpa_calc_weighted === true ? weightedQualityPoints : qualityPoints) + (g * c)) / (numCredits + c)).toFixed(2); + const cumulativeTotal = (options.gpa_calc_weighted === true ? weightedQualityPoints : qualityPoints) + (g * c); + document.querySelector("#canvasrefined-gpa-cumulative").textContent = Number.isFinite(cumulativeTotal / (numCredits + c)) ? (cumulativeTotal / (numCredits + c)).toFixed(2) : "--"; + // Mirror the live results into the open calculator modal's header tiles. + const gpaModal = document.getElementById("canvasrefined-gpa-modal"); + if (gpaModal) gpaModal.querySelectorAll("[data-gpa-mirror]").forEach(tile => { + const src = document.getElementById("canvasrefined-gpa-" + tile.dataset.gpaMirror); + if (src) tile.querySelector(".cr-gpa-result-value").textContent = src.textContent; + }); } function changeGPASettings(course_id, update) { @@ -7768,6 +7795,357 @@ function createGPACalcCourse(location, course) { return courseContainer; } +// Widgets tab position for the GPA widget. Falls back to the legacy "Move to +// top" checkbox for users who set it before the dropdown existed. +function gpaCalcPosition() { + if (options.gpa_calc_position !== undefined) return options.gpa_calc_position; + return options.gpa_calc_prepend === true ? "cards_start" : "cards_end"; +} + +// Place the GPA card (container2) and its expanded calculator (container) at +// the position chosen in the Widgets tab. Guards avoid re-inserting nodes +// that are already in place — re-inserting would ping the dashboard mutation +// observer and retrigger this whole pass. +function placeGPAElements(dashboardContainer, container, container2) { + // While the calculator is hosted in the open modal, leave it alone — a + // dashboard setup pass must not yank it out from under the user. + if (container.closest && container.closest("#canvasrefined-gpa-modal")) return; + const pos = gpaCalcPosition(); + const containerMode = pos === "top" || pos === "below"; + container.classList.toggle("container-mode", containerMode); + container2.classList.toggle("container-mode", containerMode); + const parent = dashboardContainer.parentNode; + if (containerMode) { + placeWidgetInRow("gpa_calc", pos, [container2, container], dashboardContainer); + return; + } + releaseWidgetFromRow("gpa_calc", [container2, container], dashboardContainer); + if (pos === "cards_start") { + if (dashboardContainer.children[0] !== container || dashboardContainer.children[1] !== container2) { + dashboardContainer.insertBefore(container, dashboardContainer.firstChild); + dashboardContainer.insertBefore(container2, container.nextSibling); + } + } else if (pos === "cards_end") { + if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) { + dashboardContainer.appendChild(container2); + dashboardContainer.appendChild(container); + } + } else if (pos === "top") { + if (container2.parentNode !== parent || container2.nextElementSibling !== container || container.nextElementSibling !== dashboardContainer) { + if (parent) { + parent.insertBefore(container2, dashboardContainer); + parent.insertBefore(container, container2.nextSibling); + } else { + dashboardContainer.prepend(container2); + dashboardContainer.prepend(container); + } + } + } else { // below + if (container2.parentNode !== parent || dashboardContainer.nextElementSibling !== container2 || container2.nextElementSibling !== container) { + if (parent) { + parent.insertBefore(container2, dashboardContainer.nextSibling); + parent.insertBefore(container, container2.nextSibling); + } else { + dashboardContainer.appendChild(container2); + dashboardContainer.appendChild(container); + } + } + } +} + +// Shared full-width container ("top"/"below" positions): widgets live in a +// flex row; each widget's slot width is the % chosen in the Widgets tab. +function containerWidgetOrder(area) { + const saved = options.widget_order && options.widget_order[area]; + if (!Array.isArray(saved)) return []; + return saved.map(e => { + if (typeof e === "string") return { id: e, size: null }; + if (e && typeof e === "object" && e.id) { + const n = Number(e.size); + return { id: e.id, size: Number.isFinite(n) && n > 0 ? n : null }; + } + return null; + }).filter(e => e && (e.id === "dashboard_notes" || e.id === "gpa_calc")); +} + +// 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 +// container — that's what caused GPA and Notes to build two separate rows. +function widgetContainerAnchor(container) { + if (container && container.id === "DashboardCard_Container") return container; + return document.querySelector("#DashboardCard_Container") || container; +} + +function ensureWidgetRow(area, container) { + const parent = container.parentNode; + if (!parent) return null; + let row = parent.querySelector(":scope > .canvasrefined-widget-row[data-area='" + area + "']"); + if (!row) { + row = document.createElement("div"); + row.className = "canvasrefined-widget-row"; + row.dataset.area = area; + if (area === "top") parent.insertBefore(row, container); + else parent.insertBefore(row, container.nextSibling); + } + return row; +} + +// Lay out a widget row: order slots per the saved order, size each slot by +// its saved % (normalized across the widgets actually present). +function applyWidgetRowLayout(row, area) { + const slots = [...row.querySelectorAll(":scope > .canvasrefined-widget-slot")]; + if (!slots.length) { row.remove(); return; } + const entries = containerWidgetOrder(area); + const byId = {}; + slots.forEach(slot => { byId[slot.dataset.widget] = slot; }); + const order = entries.map(e => e.id).filter(id => byId[id]); + slots.forEach(slot => { if (!order.includes(slot.dataset.widget)) order.push(slot.dataset.widget); }); + + // Only re-append when the DOM order differs, so our own layout pass + // doesn't ping the dashboard mutation observer into another pass. + const currentOrder = slots.map(s => s.dataset.widget); + if (currentOrder.join("|") !== order.join("|")) { + order.forEach(id => row.appendChild(byId[id])); + } + + // Stale dividers from a previous layout are rebuilt below. + row.querySelectorAll(":scope > .canvasrefined-widget-divider").forEach(d => d.remove()); + + // Sizing: saved % values are normalized across the widgets actually in + // the row, so when one widget leaves (or a new one joins) the rest + // instantly redistribute to fill the freed space. flex-grow weights do + // that redistribution automatically in CSS. + const present = entries.filter(e => order.includes(e.id)); + const defined = present.filter(e => Number.isFinite(e.size) && e.size > 0); + let weights; + if (defined.length === 0) { + weights = order.map(() => 1); + } else { + const totalDefined = defined.reduce((a, e) => a + e.size, 0); + const undefinedIds = order.filter(id => !defined.some(e => e.id === id)); + const byIdEntry = Object.fromEntries(present.map(e => [e.id, e])); + weights = order.map(id => { + const e = byIdEntry[id]; + if (e && Number.isFinite(e.size) && e.size > 0) return e.size / totalDefined; + return undefinedIds.length ? Math.max(0, 100 - totalDefined) / undefinedIds.length / 100 : 0.01; + }); + } + order.forEach((id, i) => { + byId[id].dataset.sizePct = weights[i] * 100; + byId[id].style.flex = weights[i] + " 1 0%"; + byId[id].style.minWidth = "0"; + }); +} + +// Put a widget (one or more elements forming one unit) into its slot in the +// shared top/below container row. +function placeWidgetInRow(id, area, els, container) { + const row = ensureWidgetRow(area, widgetContainerAnchor(container)); + if (!row) return; + let slot = row.querySelector(":scope > .canvasrefined-widget-slot[data-widget='" + id + "']"); + if (!slot) { + slot = document.createElement("div"); + slot.className = "canvasrefined-widget-slot"; + slot.dataset.widget = id; + row.appendChild(slot); + } + // If the widget sat in a slot of a DIFFERENT row (moving top <-> below), + // drop that old slot first: appendChild moves the element but leaves the + // empty slot behind, still reserving its old % width. + const first = els[0]; + const oldSlot = first && first.closest && first.closest(".canvasrefined-widget-slot"); + if (oldSlot && oldSlot !== slot) { + const oldRow = oldSlot.parentNode; + oldSlot.remove(); + if (oldRow && oldRow !== row) applyWidgetRowLayout(oldRow, oldRow.dataset.area); + } + els.forEach(el => { if (el.parentNode !== slot) slot.appendChild(el); }); + applyWidgetRowLayout(row, area); +} + +// Take a widget back out of a container row (e.g. moved into the card grid). +function releaseWidgetFromRow(id, els, container) { + const first = els[0]; + const slot = first && first.closest && first.closest(".canvasrefined-widget-slot"); + if (!slot) return; + const row = slot.parentNode; + const parent = row.parentNode; + const ref = row.dataset.area === "top" ? row.nextSibling : row; + els.forEach(el => { if (el.parentNode === slot) parent.insertBefore(el, ref); }); + slot.remove(); + if (row && !row.querySelector(":scope > .canvasrefined-widget-slot")) row.remove(); + else if (row) applyWidgetRowLayout(row, row.dataset.area); +} + +// GPA calculator modal: the "Edit Calculator" button opens a centered modal +// combining the course calculator with the grade-scale editor. Built fresh +// per open (no shared state with the dashboard DOM except the calculator +// body, which is moved in/out), and everything syncs through chrome.storage +// (gpa_calc_bounds / custom_cards / cumulative_gpa). +const GPA_BOUNDS_ORDER = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-", "F"]; + +const GPA_PLUS_MINUS_PRESET = { + "A+": { "cutoff": 97, "gpa": 4.0 }, + "A": { "cutoff": 93, "gpa": 4.0 }, + "A-": { "cutoff": 90, "gpa": 3.7 }, + "B+": { "cutoff": 87, "gpa": 3.3 }, + "B": { "cutoff": 83, "gpa": 3.0 }, + "B-": { "cutoff": 80, "gpa": 2.7 }, + "C+": { "cutoff": 77, "gpa": 2.3 }, + "C": { "cutoff": 73, "gpa": 2.0 }, + "C-": { "cutoff": 70, "gpa": 1.7 }, + "D+": { "cutoff": 67, "gpa": 1.3 }, + "D": { "cutoff": 63, "gpa": 1.0 }, + "D-": { "cutoff": 60, "gpa": 0.7 }, + "F": { "cutoff": 0, "gpa": 0 } +}; + +const GPA_BY_LETTER_PRESET = { + "A+": { "cutoff": 97, "gpa": 4.0 }, + "A": { "cutoff": 93, "gpa": 4.0 }, + "A-": { "cutoff": 90, "gpa": 4.0 }, + "B+": { "cutoff": 87, "gpa": 3.0 }, + "B": { "cutoff": 83, "gpa": 3.0 }, + "B-": { "cutoff": 80, "gpa": 3.0 }, + "C+": { "cutoff": 77, "gpa": 2.0 }, + "C": { "cutoff": 73, "gpa": 2.0 }, + "C-": { "cutoff": 70, "gpa": 2.0 }, + "D+": { "cutoff": 67, "gpa": 1.0 }, + "D": { "cutoff": 63, "gpa": 1.0 }, + "D-": { "cutoff": 60, "gpa": 1.0 }, + "F": { "cutoff": 0, "gpa": 0 } +}; + +// Grade-scale editor: rows of letter | min % | GPA points. Writes the +// gpa_calc_bounds key; the modal re-renders on storage changes (which also +// re-syncs which preset button is highlighted). +function renderGpaBoundsEditor(host) { + if (!host.isConnected) return; + chrome.storage.sync.get(["gpa_calc_bounds"], storage => { + const bounds = storage["gpa_calc_bounds"]; + if (!bounds || !host.isConnected) return; + host.textContent = ""; + GPA_BOUNDS_ORDER.forEach(key => { + const row = document.createElement("label"); + row.className = "cr-gpa-row"; + row.innerHTML = + '' + key + '' + + '%' + + 'pts'; + const [cutoff, gpa] = row.querySelectorAll("input"); + cutoff.value = bounds[key].cutoff; + gpa.value = bounds[key].gpa; + const save = field => () => { + chrome.storage.sync.get(["gpa_calc_bounds"], existing => { + chrome.storage.sync.set({ "gpa_calc_bounds": { ...existing["gpa_calc_bounds"], [key]: { ...existing["gpa_calc_bounds"][key], [field]: parseFloat(field === "cutoff" ? cutoff.value : gpa.value) } } }); + }); + }; + cutoff.addEventListener("change", save("cutoff")); + gpa.addEventListener("change", save("gpa")); + host.appendChild(row); + }); + // Highlight the preset that currently matches the saved scale, if either does. + const modal = host.closest("#canvasrefined-gpa-modal"); + if (modal) { + const json = JSON.stringify(bounds); + modal.querySelectorAll(".cr-gpa-preset").forEach(btn => { + const preset = btn.dataset.preset === "letter" ? GPA_BY_LETTER_PRESET : GPA_PLUS_MINUS_PRESET; + btn.classList.toggle("active", JSON.stringify(preset) === json); + }); + } + }); +} + +function closeGpaModal(dashboardContainer, container, container2) { + const modal = document.getElementById("canvasrefined-gpa-modal"); + if (modal) { + if (modal.__crKeyHandler) document.removeEventListener("keydown", modal.__crKeyHandler); + modal.remove(); + } + // Return the calculator body to its resting spot next to the GPA card; + // placeGPAElements corrects the position for the current widget placement. + if (container2.parentNode) container2.parentNode.insertBefore(container, container2.nextSibling); + container.style.display = "none"; + placeGPAElements(dashboardContainer, container, container2); +} + +function openGpaModal(dashboardContainer, container, container2) { + closeGpaModal(dashboardContainer, container, container2); + + const modal = document.createElement("div"); + modal.id = "canvasrefined-gpa-modal"; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + + // Only show the result tiles the user actually tracks on the card. + if (options.gpa_calc_weighted !== true) modal.querySelector('[data-gpa-mirror="weighted"]').style.display = "none"; + if (options.gpa_calc_cumulative !== true) modal.querySelector('[data-gpa-mirror="cumulative"]').style.display = "none"; + + // Seed the result tiles from the card's current numbers; calculateGPA2 + // keeps them live from here on (see its mirror block). + modal.querySelectorAll("[data-gpa-mirror]").forEach(tile => { + const src = document.getElementById("canvasrefined-gpa-" + tile.dataset.gpaMirror); + if (src) tile.querySelector(".cr-gpa-result-value").textContent = src.textContent || "--"; + }); + + // The calculator body (with all its wired course rows) MOVES into the + // modal while open — one set of course nodes in the DOM, and + // calculateGPA2 keeps working unchanged. + modal.querySelector(".cr-gpa-courses-host").appendChild(container); + container.style.display = "block"; + + const boundsHost = modal.querySelector(".cr-gpa-modal-bounds"); + renderGpaBoundsEditor(boundsHost); + modal.querySelector('[data-preset="plusminus"]').addEventListener("click", () => { + chrome.storage.sync.set({ "gpa_calc_bounds": GPA_PLUS_MINUS_PRESET }, () => renderGpaBoundsEditor(boundsHost)); + }); + modal.querySelector('[data-preset="letter"]').addEventListener("click", () => { + chrome.storage.sync.set({ "gpa_calc_bounds": GPA_BY_LETTER_PRESET }, () => renderGpaBoundsEditor(boundsHost)); + }); + + const close = () => closeGpaModal(dashboardContainer, container, container2); + // Escape closes from anywhere (the old modal only caught keys while it + // itself held focus). + modal.__crKeyHandler = (e) => { if (e.key === "Escape") close(); }; + document.addEventListener("keydown", modal.__crKeyHandler); + modal.querySelector(".cr-gpa-close").addEventListener("click", close); + modal.addEventListener("mousedown", (e) => { if (e.target === modal) close(); }); +} + function setupGPACalc() { if (current_page !== "/" && current_page !== "") return; try { @@ -7798,13 +8176,9 @@ function setupGPACalc() { container.innerHTML = '

GPA Calculator

'; - if (options.gpa_calc_prepend === true) { - dashboardContainer.prepend(container2); - dashboardContainer.prepend(container); - } else { - dashboardContainer.appendChild(container2); - dashboardContainer.appendChild(container); - } + placeGPAElements(dashboardContainer, container, container2); + editBtn.textContent = "Edit Calculator"; + editBtn.addEventListener("click", () => openGpaModal(dashboardContainer, container, container2)); let location = document.querySelector(".canvasrefined-gpa-courses"); if (!location) return; @@ -7815,20 +8189,6 @@ function setupGPACalc() { container.style.display = "none"; - editBtn.addEventListener("click", () => { - if (container.style.display === "none") { - container.style.display = "inline-block"; - editBtn.textContent = "Close Calculator"; - } else { - container.style.display = "none"; - editBtn.textContent = "Edit Calculator"; - } - // Opening/closing changes the card grid's row composition - // (the expanded calculator claims a full-width row), so - // uneven-row centering has to recompute either way. - requestAnimationFrame(centerUnevenGridRows); - }); - container2.dataset.canvasrefinedGpaRendered = "true"; container.dataset.canvasrefinedGpaRendered = "true"; } else { @@ -7837,18 +8197,7 @@ function setupGPACalc() { if (weighted) weighted.style.display = options.gpa_calc_weighted ? "block" : "none"; if (cumulative) cumulative.style.display = options.gpa_calc_cumulative ? "block" : "none"; - const shouldPrepend = options.gpa_calc_prepend === true; - if (shouldPrepend) { - if (dashboardContainer.children[0] !== container || dashboardContainer.children[1] !== container2) { - dashboardContainer.insertBefore(container, dashboardContainer.firstChild); - dashboardContainer.insertBefore(container2, container.nextSibling); - } - } else { - if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) { - dashboardContainer.appendChild(container2); - dashboardContainer.appendChild(container); - } - } + placeGPAElements(dashboardContainer, container, container2); } try { @@ -8142,6 +8491,30 @@ 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 +// 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") { + // 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 { + releaseWidgetFromRow("dashboard_notes", [notes], container); + if (pos === "cards_start") { + container.prepend(notes); + } else { + container.appendChild(notes); + } + } + notes.dataset.crPos = pos; +} + function loadDashboardNotes() { const container = document.querySelector("#DashboardCard_Container"); if (options.dashboard_notes === true) { @@ -8156,11 +8529,6 @@ function loadDashboardNotes() { notes = document.createElement("div"); notes.classList.add("canvasrefined-dashboard-notes"); notes.innerHTML = DASHBOARD_NOTES_HTML; - // Mount as a full-width sibling above the card grid. Prepending inside the - // DashboardCard_Container makes the notes a masonry/grid cell (narrow & broken). - const parent = container.parentNode; - if (parent) parent.insertBefore(notes, container); - else container.prepend(notes); wireDashboardNotes(notes); } else { notes.style.display = ""; @@ -8174,6 +8542,7 @@ function loadDashboardNotes() { renderDashboardNotesPreview(rendered, editor ? editor.value : ""); } } + placeDashboardNotes(notes, container); } else { let notes = document.querySelector('.canvasrefined-dashboard-notes'); if (notes) notes.style.display = "none"; @@ -8339,7 +8708,9 @@ function applyAestheticChanges() { style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-top:0!important;margin-left:0!important}`; const centerGrid = options.center_cards === true; const gpaOn = options.gpa_calc === true; - const gpaTop = options.gpa_calc_prepend === true; + const gpaPos = gpaCalcPosition(); + const gpaInsideGrid = gpaPos === "cards_start" || gpaPos === "cards_end"; + const gpaFirst = gpaPos === "cards_start"; const flexGrid = options.card_grid_flex === true; if (flexGrid) { // Flexible grid: fixed-width tracks (cards keep their natural @@ -8363,17 +8734,17 @@ function applyAestheticChanges() { // Grid gaps alone control spacing (beats the customCardStyles // margin rules emitted later via higher specificity). style.textContent += `.ic-DashboardCard__box__container > .ic-DashboardCard,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{margin-right:0!important;margin-bottom:0!important}`; - if (gpaOn) { - if (gpaTop) { - // Top mode: definite first-row placement, one track wide; - // the expanded calculator takes the full second row. + if (gpaOn && gpaInsideGrid) { + if (gpaFirst) { + // "Before cards": definite first-row placement, one track + // wide; the expanded calculator takes the full second row. style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-row:1!important;grid-column:1!important}`; style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-row:2!important;grid-column:1/-1!important}`; } else { - // Bottom mode: the GPA card auto-places in DOM order, so it - // flows right after the last course card — filling the next - // open slot instead of stranding below the grid — and the - // expanded calculator follows on a full-width row. + // "After cards": the GPA card auto-places in DOM order, so + // it flows right after the last course card — filling the + // next open slot instead of stranding below the grid — and + // the expanded calculator follows on a full-width row. style.textContent += `.ic-DashboardCard__box__container > .canvasrefined-gpa{grid-column:1/-1!important}`; } } @@ -8385,28 +8756,30 @@ function applyAestheticChanges() { for (let m = gridCols - 1; m >= 1; m--) { const tooWideFor = (m + 1) * cardW + m * gridColGap; 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,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{width:100%!important;min-width:0!important}}`; + 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}}`; } else { style.textContent += `@container (max-width:${tooWideFor - 1}px){.ic-DashboardCard__box__container{grid-template-columns:repeat(${m},${cardW}px)!important}}`; } } } else { - const totalRows = gridRows + (gpaOn ? 2 : 0); + const totalRows = gridRows + (gpaOn && gpaInsideGrid ? 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,.ic-DashboardCard__box__container > .canvasrefined-gpa-card{grid-column:span 2!important}`; - if (gpaOn) { - // "Move to top" (gpa_calc_prepend) decides where the GPA card - // sits: on -> pinned to the first slot (the expanded calculator - // takes the full second row, and cards auto-place around both); - // off -> the GPA card is a regular auto-placed item (one card - // slot wide via the span rule above), so it flows into the next - // open slot after the course cards — filling the leftover - // spaces of a partial last row instead of squatting alone in a - // dedicated row below the grid. The expanded calculator always - // takes a full-width row: auto-placed right after the GPA card - // in bottom mode, and display:none while collapsed so it - // consumes no cells. - if (gpaTop) { + if (gpaOn && gpaInsideGrid) { + // The Widgets position dropdown decides where the GPA card + // sits: "Start of cards" -> pinned to the first slot (the + // expanded calculator takes the full second row, and cards + // auto-place around both); "End of cards" -> the GPA card is a + // regular auto-placed item (one card slot wide via the span + // rule above), so it flows into the next open slot after the + // course cards — filling the leftover spaces of a partial last + // row instead of squatting alone in a dedicated row below the + // grid. The expanded calculator always takes a full-width row: + // auto-placed right after the GPA card in end mode, and + // display:none while collapsed so it consumes no cells. + // "Top of page"/"Below cards" place the GPA elements outside + // the grid container entirely, so no grid rules are needed. + if (gpaFirst) { style.textContent += `.ic-DashboardCard__box__container > .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}`; } else { diff --git a/js/popup.js b/js/popup.js index 792e47b..8bcce2c 100644 --- a/js/popup.js +++ b/js/popup.js @@ -128,6 +128,8 @@ const defaultOptions = { "dashboard_notes": false, "dashboard_notes_text": "", "dashboard_notes_mode": "edit", + "dashboard_notes_position": "top", + "gpa_calc_position": "cards_end", "better_todo": true, "better_sidebar": false, "sidebar_scale": 100, @@ -1001,9 +1003,219 @@ function toggleAlternateColorsVisibility(darkModeOn) { if (wrap) wrap.style.display = darkModeOn ? "none" : ""; } -// Hide a toggle's sub-options when it's off; auto_dark only hides its time clocks. +// Widgets tab: position dropdowns for widgets that support placement. +const WIDGET_LIST = [ + { id: "dashboard_notes", nameKey: "dashboard_notes" }, + { id: "gpa_calc", nameKey: "gpa_calculator" }, +]; + +function widgetPosFromSync(id, sync) { + if (id === "dashboard_notes") return sync.dashboard_notes_position || "top"; + if (id === "gpa_calc") { + if (sync.gpa_calc_position !== undefined) return sync.gpa_calc_position; + return sync.gpa_calc_prepend === true ? "cards_start" : "cards_end"; + } + return "top"; +} + +const WIDGET_ORDER_MIN_PCT = 15; +const WIDGET_ORDER_SNAP_PCT = 4; + +// widget_order entries: [{id, size}] where size is the widget's % width of +// the shared container. Legacy plain-id arrays are treated as equal sizes. +function parseWidgetOrderEntries(raw) { + if (!Array.isArray(raw)) return []; + return raw.map(e => { + if (typeof e === "string") return { id: e, size: null }; + if (e && typeof e === "object" && e.id) { + const n = Number(e.size); + return { id: e.id, size: Number.isFinite(n) && n > 0 ? n : null }; + } + return null; + }).filter(e => e && WIDGET_LIST.some(w => w.id === e.id)); +} + +function widgetDisplayName(id) { + const info = WIDGET_LIST.find(w => w.id === id); + return (info && chrome.i18n.getMessage(info.nameKey)) || id; +} + +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 render = (sync) => { + const areas = { top: [], 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); + }); + let any = false; + ["top", "below"].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]; + 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)); + ids.forEach(id => { if (!entries.some(e => e.id === id)) entries.push({ id, size: null }); }); + renderWidgetOrderStrip(strip, entries); + }); + wrap.style.display = any ? "" : "none"; + }; + + // Reordering: drag a box horizontally past a neighbor. + ["top", "below"].forEach(area => { + const strip = document.getElementById("widget-order-" + area); + if (!strip) return; + strip.addEventListener("dragover", e => { + e.preventDefault(); + const dragging = strip.querySelector(".wos-item.dragging"); + if (!dragging) return; + let ref = null; + for (const el of strip.querySelectorAll(".wos-item:not(.dragging)")) { + const box = el.getBoundingClientRect(); + if (e.clientX < box.left + box.width / 2) { ref = el; break; } + } + if (ref) strip.insertBefore(dragging, ref); + else strip.appendChild(dragging); + }); + strip.addEventListener("drop", e => e.preventDefault()); + }); + + const refresh = () => chrome.storage.sync.get(KEYS, render); + + refresh(); + chrome.storage.onChanged.addListener((changes, area) => { + if (area !== "sync") return; + if (KEYS.some(k => changes[k])) refresh(); + }); +} + +function renderWidgetOrderStrip(strip, entries) { + strip.textContent = ""; + entries.forEach((entry, i) => { + if (i > 0) { + const divider = document.createElement("div"); + divider.className = "wos-divider"; + wireWidgetOrderDivider(strip, divider); + strip.appendChild(divider); + } + const item = document.createElement("div"); + item.className = "wos-item"; + item.dataset.id = entry.id; + 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 => { + item.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + try { e.dataTransfer.setData("text/plain", entry.id); } catch (_) {} + }); + item.addEventListener("dragend", () => { + item.classList.remove("dragging"); + saveWidgetStrip(strip.dataset.area, strip); + }); + strip.appendChild(item); + }); +} + +// Dragging a divider resizes its two neighboring boxes; the divider position +// is exactly the % split between them. +function wireWidgetOrderDivider(strip, divider) { + divider.addEventListener("pointerdown", e => { + e.preventDefault(); + try { divider.setPointerCapture(e.pointerId); } catch (_) {} + const idx = [...strip.children].indexOf(divider); + const prev = strip.children[idx - 1]; + const next = strip.children[idx + 1]; + if (!prev || !next || !prev.classList.contains("wos-item") || !next.classList.contains("wos-item")) return; + const stripWidth = strip.getBoundingClientRect().width || 1; + const startX = e.clientX; + const p0 = parseFloat(prev.dataset.size); + const n0 = parseFloat(next.dataset.size); + const onMove = ev => { + const dx = (ev.clientX - startX) / stripWidth * 100; + let p = p0 + dx; + p = Math.max(WIDGET_ORDER_MIN_PCT, Math.min(p, p0 + n0 - WIDGET_ORDER_MIN_PCT)); + // Snap to even fractions of the widget count: with 2 widgets the + // bar snaps to 50/50, with 3 to the 1/3 and 2/3 marks, etc. + const count = strip.querySelectorAll(".wos-item").length; + for (let k = 1; k < count; k++) { + const mark = k / count * 100; + if (Math.abs(p - mark) < WIDGET_ORDER_SNAP_PCT) { p = mark; break; } + } + const n = p0 + n0 - p; + prev.dataset.size = p; + next.dataset.size = n; + prev.style.flexBasis = p + "%"; + next.style.flexBasis = n + "%"; + }; + const onUp = () => { + divider.removeEventListener("pointermove", onMove); + divider.removeEventListener("pointerup", onUp); + divider.removeEventListener("pointercancel", onUp); + saveWidgetStrip(strip.dataset.area, strip); + }; + divider.addEventListener("pointermove", onMove); + divider.addEventListener("pointerup", onUp); + divider.addEventListener("pointercancel", onUp); + }); +} + +function saveWidgetStrip(area, strip) { + const items = [...strip.querySelectorAll(".wos-item")]; + if (items.length < 2) return; + 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; + chrome.storage.sync.set({ widget_order: wo }); + }); +} + +function setupWidgetPosition() { + [ + { select: "dashboard_notes_position", key: "dashboard_notes_position" }, + { select: "gpa_calc_position", key: "gpa_calc_position" }, + ].forEach(({ select, key }) => { + const el = document.getElementById(select); + if (!el) return; + chrome.storage.sync.get([key, "gpa_calc_prepend"], sync => { + let value = sync[key]; + if (value === undefined && key === "gpa_calc_position") { + // Legacy "Move to top" checkbox -> equivalent dropdown position. + value = sync["gpa_calc_prepend"] === true ? "cards_start" : "cards_end"; + chrome.storage.sync.set({ [key]: value }); + } + el.value = value || defaultOptions.sync[key] || "top"; + }); + el.addEventListener("change", function () { + chrome.storage.sync.set({ [key]: this.value }); + }); + }); +} + +// Hide a toggle's sub-options when it's off; auto_dark only hides its time +// clocks. function toggleSubOptionsVisibility(option, isOn) { - const togglesWithSubOptions = ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"]; + const togglesWithSubOptions = ["dashboard_notes", "gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"]; if (!togglesWithSubOptions.includes(option)) return; const optionEl = document.getElementById(option); if (!optionEl) return; @@ -1162,6 +1374,15 @@ function setupFeatureSearch(menu) { add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToMainElement(opt) }); }); + // Tabs: option toggles that live inside a tab (e.g. Dashboard Notes in Widgets) + document.querySelectorAll(".tab .option").forEach(opt => { + if (!opt.id) return; + const name = opt.querySelector(".option-name"); + if (!name) return; + if (shouldSkip(opt)) return; + add({ key: "option:" + opt.id, text: name.textContent.trim(), el: opt, action: () => goToTabElement(opt) }); + }); + // Home: sub-options / timesets / labelled rows (e.g. "Use dd/mm", "Start time", max-items) document.querySelectorAll(".options .sub-option, .options .timeset, .options .sub-options > div").forEach(sub => { if (shouldSkip(sub)) return; @@ -1230,7 +1451,7 @@ function setupFeatureSearch(menu) { }); // Tab: standalone action buttons with distinct labels (GPA scale presets, revert colors). - ["gpa-plus-minus", "gpa-by-letter", "revert-colors", "clearCustomBackground"].forEach(id => { + ["revert-colors", "clearCustomBackground"].forEach(id => { const btn = document.getElementById(id); if (!btn || shouldSkip(btn)) return; const text = btn.textContent.trim(); @@ -1378,7 +1599,6 @@ function setup() { "hide_completed_cards", "equal_height_cards", // "todo_overdues", - "gpa_calc_prepend", "auto_dark", "assignment_date_format", "custom_font_skip_p", @@ -1411,14 +1631,15 @@ function setup() { setup: displayAdvancedCards, tab: ".advanced", }, - "gpa-bounds-btn": { - setup: displayGPABounds, - tab: ".gpa-bounds-container", - }, + "custom-font-btn": { setup: displayCustomFont, tab: ".custom-font-container", }, + "gpa-bounds-btn": { + setup: null, + tab: ".gpa-bounds-container", + }, "card-colors-btn": { setup: null, tab: ".card-colors-container" }, "customize-dark-btn": { setup: displayDarkModeFixUrls, @@ -1645,12 +1866,15 @@ function setup() { chrome.storage.sync.get("todo_celebrations_enabled", v => { toggleCelebrationOptionsVisibility(v["todo_celebrations_enabled"] !== false); }); - ["gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => { + ["dashboard_notes", "gpa_calc", "assignments_due", "better_todo", "auto_dark", "grade_analytics", "hide_personal_details"].forEach(opt => { toggleSubOptionsVisibility(opt, sync[opt] === true); }); toggleAlternateColorsVisibility(sync["dark_mode"] === true); }); + setupWidgetPosition(); + setupWidgetOrder(); + chrome.storage.sync.get(menu.checkboxes, sync => { menu.checkboxes.forEach(option => { const checkbox = document.querySelector("#" + option); @@ -2208,41 +2432,6 @@ function setup() { document.getElementById("submit-form-btn").addEventListener("click", displayThemeSubmissionForm); - document.getElementById("gpa-plus-minus").addEventListener("click", () => { - applyGPAPreset({ - "A+": { "cutoff": 97, "gpa": 4.0 }, - "A": { "cutoff": 93, "gpa": 4.0 }, - "A-": { "cutoff": 90, "gpa": 3.7 }, - "B+": { "cutoff": 87, "gpa": 3.3 }, - "B": { "cutoff": 83, "gpa": 3.0 }, - "B-": { "cutoff": 80, "gpa": 2.7 }, - "C+": { "cutoff": 77, "gpa": 2.3 }, - "C": { "cutoff": 73, "gpa": 2.0 }, - "C-": { "cutoff": 70, "gpa": 1.7 }, - "D+": { "cutoff": 67, "gpa": 1.3 }, - "D": { "cutoff": 63, "gpa": 1.0 }, - "D-": { "cutoff": 60, "gpa": 0.7 }, - "F": { "cutoff": 0, "gpa": 0 } - }); - }); - document.getElementById("gpa-by-letter").addEventListener("click", () => { - applyGPAPreset({ - "A+": { "cutoff": 97, "gpa": 4.0 }, - "A": { "cutoff": 93, "gpa": 4.0 }, - "A-": { "cutoff": 90, "gpa": 4.0 }, - "B+": { "cutoff": 87, "gpa": 3.0 }, - "B": { "cutoff": 83, "gpa": 3.0 }, - "B-": { "cutoff": 80, "gpa": 3.0 }, - "C+": { "cutoff": 77, "gpa": 2.0 }, - "C": { "cutoff": 73, "gpa": 2.0 }, - "C-": { "cutoff": 70, "gpa": 2.0 }, - "D+": { "cutoff": 67, "gpa": 1.0 }, - "D": { "cutoff": 63, "gpa": 1.0 }, - "D-": { "cutoff": 60, "gpa": 1.0 }, - "F": { "cutoff": 0, "gpa": 0 } - }); - }); - // Note: the card-style number inputs (imageSize, cardRoundness, // imageRoundness, cardSpacing, cardWidth, cardHeight, cardPadding) are wired // via their setup*Input @@ -2292,13 +2481,6 @@ function setup() { if (skipPToggle) { skipPToggle.closest("span").addEventListener("click", (e) => e.stopPropagation()); } - -} - -function applyGPAPreset(bounds) { - chrome.storage.sync.set({ "gpa_calc_bounds": bounds }, () => { - displayGPABounds(); - }); } function setupCustomStyle(initial) { @@ -2727,33 +2909,6 @@ function displayCustomFont() { }); } -function displayGPABounds() { - chrome.storage.sync.get(["gpa_calc_bounds"], storage => { - const order = ["A+", "A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D+", "D", "D-", "F"]; - const el = document.querySelector(".gpa-bounds"); - el.textContent = ""; - order.forEach(key => { - let inputs = makeElement("div", el, { "className": "gpa-bounds-item" }); - inputs.innerHTML += '
%GPA
'; - inputs.querySelector(".gpa-bounds-grade").textContent = key; - inputs.querySelector(".gpa-bounds-cutoff").value = storage["gpa_calc_bounds"][key].cutoff; - inputs.querySelector(".gpa-bounds-gpa").value = storage["gpa_calc_bounds"][key].gpa; - - inputs.querySelector(".gpa-bounds-cutoff").addEventListener("change", function (e) { - chrome.storage.sync.get(["gpa_calc_bounds"], existing => { - chrome.storage.sync.set({ "gpa_calc_bounds": { ...existing["gpa_calc_bounds"], [key]: { ...existing["gpa_calc_bounds"][key], "cutoff": parseFloat(e.target.value) } } }); - }); - }); - - inputs.querySelector(".gpa-bounds-gpa").addEventListener("change", function (e) { - chrome.storage.sync.get(["gpa_calc_bounds"], existing => { - chrome.storage.sync.set({ "gpa_calc_bounds": { ...existing["gpa_calc_bounds"], [key]: { ...existing["gpa_calc_bounds"][key], "gpa": parseFloat(e.target.value) } } }); - }); - }); - }); - }); -} - let removeAlert = null; function clearAlert() {