Compare commits

...

2 Commits

Author SHA1 Message Date
Guy Sandler
933a5a506b cool feature 1 2026-08-26 21:31:33 -07:00
Guy Sandler
97b2612062 fixed return to assignment button 2026-08-26 19:26:08 -07:00
7 changed files with 722 additions and 43 deletions

View File

@ -63,6 +63,7 @@ Canvas Refined adds more with more to come!
- Popup UI revamp
- NEW Better todo list
- Better sidebar
- Global Canvas Search (search modules & assignments across all courses; Ctrl/Cmd+K)
- Simplified UI
- Some options won't show up when not needed
- Markdown in dashboard notes
@ -76,7 +77,6 @@ Canvas Refined adds more with more to come!
- mail assistant + ui revamp
- better calender (+ calender sync)
- better what if grade
- global canvas search
- fix darkmode fixer (and automatic)
- grade history with graph

View File

@ -304,5 +304,8 @@
},
"hide_new_canvas": {
"message": "Hide New Dashboard"
},
"global_search": {
"message": "Global Canvas Search"
}
}

View File

@ -266,3 +266,106 @@
color: #000 !important;
}
.canvasrefined-quiz-safe-dismiss:hover { background: #ececec; }
/* ===== Global Canvas Search ===== */
.canvasrefined-gs-fab {
position: fixed; right: 18px; bottom: 18px; z-index: 2147483000;
width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
color: #fff; background: var(--bclinks, #0072b5);
box-shadow: 0 4px 14px rgba(0,0,0,.28);
transition: transform .12s ease, filter .12s ease;
}
.canvasrefined-gs-fab:hover { transform: translateY(-2px) scale(1.04); filter: brightness(1.08); }
.canvasrefined-gs-fab:active { transform: translateY(0) scale(0.97); }
/* Dashboard header trigger — sits just left of the \u22ee options button. */
.canvasrefined-gs-header-btn {
display: inline-flex; align-items: center; gap: 6px;
margin-right: 10px; padding: 6px 12px;
border: 1px solid var(--bcborders, #c7cdd1); border-radius: 6px;
background: var(--bcbackground-2, #f5f7f8); color: var(--bctext-1, #5b6770);
font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
line-height: 1; white-space: nowrap;
transition: background .12s ease, border-color .12s ease;
}
.canvasrefined-gs-header-btn:hover { background: var(--bcbackground-1, #ececec); border-color: var(--bctext-2, #9aa3ab); }
.canvasrefined-gs-header-btn:active { transform: translateY(1px); }
.canvasrefined-gs-header-btn-label { line-height: 1; }
@media (max-width: 768px) { .canvasrefined-gs-header-btn-label { display: none; } }
.canvasrefined-gs-modal {
position: fixed; inset: 0; z-index: 2147483100;
background: rgba(0,0,0,.42);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 12vh; animation: canvasrefined-gs-fade .12s ease;
}
@keyframes canvasrefined-gs-fade { from { opacity: 0; } to { opacity: 1; } }
.canvasrefined-gs-card {
width: min(640px, 92vw); max-height: 70vh; display: flex; flex-direction: column;
background: var(--bcbackground-0, #fff); color: var(--bctext-0, #2d3b45);
border: 1px solid var(--bcborders, #c7cdd1); border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.35); overflow: hidden;
}
.canvasrefined-gs-input-row {
display: flex; align-items: center; gap: 10px; padding: 12px 14px;
border-bottom: 1px solid var(--bcborders, #e2e6e8);
background: var(--bcbackground-1, #fff);
}
.canvasrefined-gs-input-icon { color: var(--bctext-2, #9aa3ab); flex-shrink: 0; }
.canvasrefined-gs-input {
flex: 1; border: none; outline: none; background: transparent;
font: inherit; font-size: 16px; color: var(--bctext-0, #2d3b45);
}
.canvasrefined-gs-input::placeholder { color: var(--bctext-2, #9aa3ab); }
.canvasrefined-gs-close {
border: 1px solid var(--bcborders, #c7cdd1); border-radius: 6px;
background: var(--bcbackground-2, #f5f7f8); color: var(--bctext-1, #5b6770);
font: inherit; font-size: 12px; font-weight: 600; padding: 3px 8px; cursor: pointer;
}
.canvasrefined-gs-close:hover { background: var(--bcbackground-2, #ececec); }
.canvasrefined-gs-results { flex: 1; overflow-y: auto; padding: 6px; }
.canvasrefined-gs-hint, .canvasrefined-gs-loading {
padding: 18px 14px; text-align: center; color: var(--bctext-2, #9aa3ab); font-size: 14px;
}
.canvasrefined-gs-row {
display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px;
cursor: pointer; border: 1px solid transparent;
}
.canvasrefined-gs-row:hover { background: var(--bcbackground-2, rgba(0,0,0,.04)); }
.canvasrefined-gs-row.canvasrefined-gs-selected {
background: color-mix(in srgb, var(--bclinks, #0072b5) 16%, transparent);
border-color: color-mix(in srgb, var(--bclinks, #0072b5) 40%, transparent);
}
.canvasrefined-gs-row-main { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.canvasrefined-gs-title {
font-size: 14px; font-weight: 600; color: var(--bctext-0, #2d3b45);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.canvasrefined-gs-course {
font-size: 12px; color: var(--bctext-2, #9aa3ab); white-space: nowrap; flex-shrink: 0;
max-width: 38%; overflow: hidden; text-overflow: ellipsis;
}
.canvasrefined-gs-type {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
padding: 2px 7px; border-radius: 6px; flex-shrink: 0;
background: var(--bcbackground-2, #eef0f2); color: var(--bctext-1, #5b6770);
border: 1px solid var(--bcborders, #dde1e3);
}
.canvasrefined-gs-type-assignment { background: #e3f1fb; color: #0b5d8c; border-color: #b9d9ef; }
.canvasrefined-gs-type-quiz { background: #fde9d9; color: #9a5300; border-color: #f6cdac; }
.canvasrefined-gs-type-discussion { background: #e6f4ea; color: #1a7a3c; border-color: #c5e6cf; }
.canvasrefined-gs-type-module { background: #efe7fb; color: #5b2a9e; border-color: #d8c6f1; }
.canvasrefined-gs-type-page { background: #f1f3f5; color: #444; border-color: #dde1e3; }
.canvasrefined-gs-type-file { background: #f1f3f5; color: #444; border-color: #dde1e3; }
.canvasrefined-gs-type-link, .canvasrefined-gs-type-external-tool { background: #fff4d6; color: #7a5b00; border-color: #f1dfab; }
.canvasrefined-gs-footer {
display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 14px; font-size: 12px;
color: var(--bctext-2, #9aa3ab); border-top: 1px solid var(--bcborders, #e2e6e8);
background: var(--bcbackground-1, #fff);
}
.canvasrefined-gs-footer kbd {
font: inherit; font-size: 11px; background: var(--bcbackground-2, #eef0f2);
border: 1px solid var(--bcborders, #c7cdd1); border-bottom-width: 2px; border-radius: 4px;
padding: 0 4px; color: var(--bctext-1, #5b6770);
}

View File

@ -203,6 +203,14 @@
<span class="cr-info-bubble-text" data-i18n="quiz_safe_mode_desc">Turns off most Canvas Refined features on quiz pages so you get the default Canvas quiz experience.</span>
</span>
</div>
<div class="option" id="global_search">
<input type="radio" id="off" name="global_search">
<input type="radio" id="on" name="global_search">
<div class="slider">
<div class="sliderknob"></div>
<div class="sliderbg"></div>
</div><span class="option-name" data-i18n="global_search">Global Canvas Search</span>
</div>
</div>
<div class="social-buttons">
<a class="discord-button" href="https://discord.gg/GjFh4JA3wh" target="_blank" rel="noopener noreferrer" title="Join our Discord">

View File

@ -115,6 +115,7 @@ chrome.runtime.onInstalled.addListener(function () {
"sidebar_opacity": 100,
"bg_blur": 8,
"sidebar_blur": 0,
"global_search": false,
}
};

View File

@ -81,29 +81,60 @@ function quizSafeModeActive() {
return isQuizPage() && options.quiz_safe_mode === true;
}
// Read the URL live: Canvas' "New Canvas" UI navigates client-side via
// history.pushState/replaceState, and current_page (captured at document_start)
// can be stale when the user clicks into a submission page. Reading
// window.location.pathname at check time makes this correct regardless.
function getSubmissionAssignmentLink() {
const match = current_page.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/);
const match = window.location.pathname.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/);
if (!match) return null;
return `${domain}/courses/${match[1]}/assignments/${match[2]}/`;
}
// The content container isn't always #content on every Canvas layout (submission
// pages in particular may render into .ic-Layout-contentMain or #main). Match the
// quiz-safe-mode banner's container finder so the button lands in the visible
// content area; fall back to <body> so injection never silently no-ops.
function findContentContainer() {
return document.querySelector(".ic-Layout-contentMain")
|| document.getElementById("content")
|| document.querySelector("#main")
|| document.body;
}
let submissionPageButtonObserver = null;
let submissionButtonScheduled = false;
let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null;
function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return;
const content = document.getElementById("content");
if (!content || content.querySelector("#canvasrefined-assignment-return")) return;
// Place the button inline with the "Submission Details" heading and the
// grade-values table, inside the .submission-details-header__heading-and-grades
// flex row (appended so it sits to the right of the grade summary). Only inject
// once that row exists; if it's not there yet the persistent MutationObserver
// re-tries on the next DOM change so we never fall back to body/#content
// (which would put the button at the bottom of the page).
const row = document.querySelector(".submission-details-header__heading-and-grades")
|| document.querySelector(".submission-details-header")
|| document.querySelector(".submission_details");
if (!row || row.querySelector("#canvasrefined-assignment-return")) return;
makeElement("a", content, {
// Insert between the h1 heading and the grade-summary div so it reads
// [Heading] [Back to Assignment] [Grade]. Falls back to appending if the
// grade-summary div isn't found for some reason.
const gradeSummary = row.querySelector(".submission-details-header__grade-summary");
const btn = makeElement("a", row, {
id: "canvasrefined-assignment-return",
className: "canvasrefined-custom-btn",
href: assignmentLink,
textContent: "Back to Assignment",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:flex-start;margin:0 0 12px 0;padding:10px 14px;text-decoration:none;font-weight:700;",
}, true);
style: "display:inline-flex;align-items:center;justify-content:center;align-self:center;margin-left:auto;margin-right:12px;padding:6px 12px;text-decoration:none;font-weight:700;color:inherit!important;",
});
if (gradeSummary && gradeSummary.parentNode === row) {
row.insertBefore(btn, gradeSummary);
}
}
function addProfileLogoutPageButton() {
@ -153,19 +184,26 @@ function watchProfileLogoutPageButton() {
}, 10000);
}
function ensureSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return false;
const content = document.getElementById("content");
if (!content) return false;
const existing = content.querySelector("#canvasrefined-assignment-return");
if (existing) {
// Keep the href in sync when navigating between submission pages via SPA routing.
existing.href = assignmentLink;
return true;
}
addSubmissionPageButton();
return Boolean(content.querySelector("#canvasrefined-assignment-return"));
// Reconcile the button against the current page on a rAF-throttled schedule.
// Canvas (React-based "New Canvas" UI) re-renders the content area on SPA
// navigation and can wipe our injected node; a persistent observer re-adds it.
function maintainSubmissionPageButton() {
if (submissionButtonScheduled) return;
submissionButtonScheduled = true;
requestAnimationFrame(() => {
submissionButtonScheduled = false;
const link = getSubmissionAssignmentLink();
const existing = document.getElementById("canvasrefined-assignment-return");
if (!link) {
existing?.remove();
return;
}
if (existing) {
if (existing.href !== link) existing.href = link;
return;
}
addSubmissionPageButton();
});
}
function isAssignmentPage() {
@ -201,29 +239,19 @@ function watchSequenceFooter() {
}, 10000);
}
// One persistent, rAF-throttled observer that keeps the button present whenever
// we're on a submission page. Unlike the old 10s-disconnecting observer, this
// survives Canvas' post-navigation re-renders that remove injected nodes. The
// extra delayed checks cover React hydration that wipes the button after our
// first add without emitting any later mutation for the observer to catch.
function watchSubmissionPageButton() {
if (!getSubmissionAssignmentLink()) {
// Navigated away from a submission page; remove any stale button left in #content.
document.getElementById("canvasrefined-assignment-return")?.remove();
return;
}
if (ensureSubmissionPageButton()) return;
if (submissionPageButtonObserver) return;
submissionPageButtonObserver = new MutationObserver(() => {
if (ensureSubmissionPageButton() && submissionPageButtonObserver) {
submissionPageButtonObserver.disconnect();
submissionPageButtonObserver = null;
}
});
maintainSubmissionPageButton();
submissionPageButtonObserver = new MutationObserver(maintainSubmissionPageButton);
submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (submissionPageButtonObserver) {
submissionPageButtonObserver.disconnect();
submissionPageButtonObserver = null;
}
}, 10000);
for (const ms of [300, 800, 1600, 3000, 5000]) {
setTimeout(maintainSubmissionPageButton, ms);
}
}
function removeNewCanvasButton() {
@ -619,6 +647,15 @@ function startExtension() {
});
footerObserver.observe(document.documentElement, { childList: true, subtree: true });
// Start the submission-page "Back to Assignment" button watcher and the SPA
// navigation hook immediately, before the async storage callbacks below.
// These don't depend on `options`, and running them first means a throw in
// any later init step can't prevent the button from appearing. The watcher
// reads window.location.pathname live and uses a persistent MutationObserver
// to (re)inject the button once the content container exists.
setupNavigationListener();
watchSubmissionPageButton();
toggleDarkMode();
// Include bg_opacity/bg_blur so setupBetterSidebar (called below) tints the
@ -644,12 +681,12 @@ function startExtension() {
applyCustomBackground();
ensureBetterSidebar();
watchSequenceFooter();
watchSubmissionPageButton();
watchProfileLogoutPageButton();
setupNavigationListener();
setupQuizSafeModeBanner();
setupGlobalSearch();
setTimeout(() => runDarkModeFixer(false), 800);
setTimeout(() => runDarkModeFixer(false), 4500);
@ -788,6 +825,13 @@ function applyOptionsChanges(changes) {
case "hide_new_canvas":
watchNewCanvasButton();
break;
case "global_search":
if (options.global_search) {
setupGlobalSearch();
} else {
removeGlobalSearch();
}
break;
case "customBackgroundScale":
applyCustomBackground();
break;
@ -5487,6 +5531,526 @@ function injectQuizSafeModeBanner(safeModeOn) {
}
// =============================================================================
// Global Canvas Search
// Search across all of the user's courses for modules, module items, and
// assignments, then jump straight to them. Triggered by a floating search
// button (bottom-right) or Ctrl/Cmd+K.
// =============================================================================
let globalSearchIndex = null; // [{type,title,course,courseId,url}]
let globalSearchIndexPromise = null; // in-flight build so concurrent opens share one fetch
let globalSearchIndexAt = 0; // ms timestamp of last successful build
const GLOBAL_SEARCH_INDEX_TTL = 10 * 60 * 1000; // 10 minutes
const GLOBAL_SEARCH_STORAGE_KEY = "canvasrefined_global_search_index";
let _gsShortcutBound = false;
function setupGlobalSearch() {
if (options.global_search !== true) return;
// Rebuild the index fresh on every page load so newly-concluded/hidden
// courses never linger from a previous session's cache.
invalidateGlobalSearchIndex();
ensureGlobalSearchButton();
ensureGlobalSearchShortcut();
}
function removeGlobalSearch() {
document.getElementById("canvasrefined-global-search-btn")?.remove();
document.getElementById("canvasrefined-global-search-header-btn")?.remove();
closeGlobalSearchModal();
if (_gsPlacementObserver) { _gsPlacementObserver.disconnect(); _gsPlacementObserver = null; }
if (_gsShortcutBound) {
document.removeEventListener("keydown", onGlobalSearchShortcut, true);
_gsShortcutBound = false;
}
}
// Placement: on the dashboard the trigger lives in the header (between the
// "Dashboard" heading and the options menu, right-aligned). Everywhere else it
// falls back to a floating action button. A rAF-debounced MutationObserver
// re-evaluates placement as Canvas renders/SPA-navigates.
let _gsPlacementObserver = null;
let _gsPlacementScheduled = false;
function ensureGlobalSearchButton() {
placeGlobalSearchTrigger();
if (_gsPlacementObserver) return;
_gsPlacementObserver = new MutationObserver(() => {
if (_gsPlacementScheduled) return;
_gsPlacementScheduled = true;
requestAnimationFrame(() => {
_gsPlacementScheduled = false;
placeGlobalSearchTrigger();
});
});
_gsPlacementObserver.observe(document.documentElement, { childList: true, subtree: true });
}
function placeGlobalSearchTrigger() {
if (options.global_search !== true) return;
const headerActions = document.querySelector(".ic-Dashboard-header__actions");
if (isDashboardPage() && headerActions) {
ensureGlobalSearchHeaderButton(headerActions);
document.getElementById("canvasrefined-global-search-btn")?.remove();
} else {
document.getElementById("canvasrefined-global-search-header-btn")?.remove();
ensureGlobalSearchFab();
}
}
function ensureGlobalSearchFab() {
if (document.getElementById("canvasrefined-global-search-btn")) return;
const btn = document.createElement("button");
btn.id = "canvasrefined-global-search-btn";
btn.type = "button";
btn.className = "canvasrefined-gs-fab";
btn.title = "Search Canvas (Ctrl+K)";
btn.setAttribute("aria-label", "Search Canvas");
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="22" height="22"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>`;
btn.addEventListener("click", openGlobalSearchModal);
if (document.body) {
document.body.appendChild(btn);
} else {
const wait = new MutationObserver(() => {
if (document.body) { document.body.appendChild(btn); wait.disconnect(); }
});
wait.observe(document.documentElement, { childList: true });
}
}
function ensureGlobalSearchHeaderButton(headerActions) {
if (headerActions.querySelector("#canvasrefined-global-search-header-btn")) return;
const btn = document.createElement("button");
btn.id = "canvasrefined-global-search-header-btn";
btn.type = "button";
btn.className = "canvasrefined-gs-header-btn";
btn.title = "Search Canvas (Ctrl+K)";
btn.setAttribute("aria-label", "Search Canvas");
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="18" height="18"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg><span class="canvasrefined-gs-header-btn-label">Search</span>`;
btn.addEventListener("click", openGlobalSearchModal);
// Insert as the first child of the actions row so it sits just to the left
// of the "Dashboard Options" (⋯) button, right-aligned with it.
headerActions.insertBefore(btn, headerActions.firstChild);
}
function ensureGlobalSearchShortcut() {
if (_gsShortcutBound) return;
document.addEventListener("keydown", onGlobalSearchShortcut, true);
_gsShortcutBound = true;
}
function onGlobalSearchShortcut(e) {
// Ctrl/Cmd+K toggles the search modal. Ignore when a modal is already open
// and the user is typing in its input (handled by the modal's own listener).
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
const modal = document.getElementById("canvasrefined-global-search-modal");
if (modal && modal.dataset.open === "true") {
closeGlobalSearchModal();
} else {
e.preventDefault();
openGlobalSearchModal();
}
}
}
function openGlobalSearchModal() {
if (document.getElementById("canvasrefined-global-search-modal")) return;
const modal = document.createElement("div");
modal.id = "canvasrefined-global-search-modal";
modal.className = "canvasrefined-gs-modal";
modal.dataset.open = "true";
modal.innerHTML = `
<div class="canvasrefined-gs-card" role="dialog" aria-modal="true" aria-label="Search Canvas">
<div class="canvasrefined-gs-input-row">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" width="20" height="20" class="canvasrefined-gs-input-icon"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input id="canvasrefined-gs-input" class="canvasrefined-gs-input" type="text" placeholder="Search modules & assignments\u2026" autocomplete="off" spellcheck="false" />
<button id="canvasrefined-gs-close" class="canvasrefined-gs-close" type="button" title="Close (Esc)">Esc</button>
</div>
<div id="canvasrefined-gs-results" class="canvasrefined-gs-results"></div>
<div class="canvasrefined-gs-footer">
<span><kbd>\u2191</kbd><kbd>\u2193</kbd> navigate</span>
<span><kbd>Enter</kbd> open</span>
<span><kbd>Ctrl</kbd>+<kbd>Enter</kbd> new tab</span>
<span><kbd>Esc</kbd> close</span>
</div>
</div>`;
document.body.appendChild(modal);
const input = modal.querySelector("#canvasrefined-gs-input");
const resultsEl = modal.querySelector("#canvasrefined-gs-results");
const closeBtn = modal.querySelector("#canvasrefined-gs-close");
let selected = -1;
let currentResults = [];
closeBtn.addEventListener("click", closeGlobalSearchModal);
modal.addEventListener("mousedown", (e) => { if (e.target === modal) closeGlobalSearchModal(); });
// Escape closes; arrows + enter navigate. Bound on capture so we win over
// the global Ctrl+K toggle.
modal.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeGlobalSearchModal(); return; }
if (e.key === "ArrowDown") {
e.preventDefault();
selected = Math.min(selected + 1, currentResults.length - 1);
renderGlobalSearchSelection(resultsEl, selected);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selected = Math.max(selected - 1, 0);
renderGlobalSearchSelection(resultsEl, selected);
} else if (e.key === "Enter") {
e.preventDefault();
const item = currentResults[selected];
if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey);
}
});
// Stop the global Ctrl+K handler from closing the modal while typing.
input.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) e.stopPropagation();
});
let debounce = null;
input.addEventListener("input", () => {
clearTimeout(debounce);
debounce = setTimeout(() => runGlobalSearch(input.value.trim(), resultsEl).then(res => {
currentResults = res;
selected = res.length ? 0 : -1;
renderGlobalSearchSelection(resultsEl, selected);
}), 120);
});
// Block the page scroll while the modal is open.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
modal._restoreOverflow = () => { document.body.style.overflow = prevOverflow; };
requestAnimationFrame(() => input.focus());
// Kick off indexing immediately so the first keystroke is fast.
ensureGlobalSearchIndex();
// Render an initial hint.
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments.</div>`;
}
function closeGlobalSearchModal() {
const modal = document.getElementById("canvasrefined-global-search-modal");
if (!modal) return;
if (typeof modal._restoreOverflow === "function") modal._restoreOverflow();
modal.remove();
}
function openGlobalSearchResult(item, newTab) {
if (!item || !item.url) return;
if (newTab) {
// Opening in a new tab keeps the search menu open so the user can keep
// searching. Refocus the input for the next keystroke.
window.open(item.url, "_blank", "noopener");
const input = document.getElementById("canvasrefined-gs-input");
if (input) input.focus();
} else {
closeGlobalSearchModal();
window.location.href = item.url;
}
}
function renderGlobalSearchSelection(resultsEl, selected) {
const rows = resultsEl.querySelectorAll(".canvasrefined-gs-row");
rows.forEach((row, i) => {
if (i === selected) { row.classList.add("canvasrefined-gs-selected"); row.scrollIntoView({ block: "nearest" }); }
else row.classList.remove("canvasrefined-gs-selected");
});
}
// --- Indexing ---------------------------------------------------------------
// Drop any cached index so the next access rebuilds it from the API. Called
// at setup time so every page load starts fresh.
function invalidateGlobalSearchIndex() {
globalSearchIndex = null;
globalSearchIndexAt = 0;
globalSearchIndexPromise = null;
try { chrome.storage.local.remove(GLOBAL_SEARCH_STORAGE_KEY); } catch (_) { /* ignore */ }
}
async function ensureGlobalSearchIndex() {
// A per-session in-memory build is shared across opens so we don't refetch
// on every keystroke, but we never serve a persisted cache across reloads.
if (globalSearchIndex && (Date.now() - globalSearchIndexAt) < GLOBAL_SEARCH_INDEX_TTL) {
return globalSearchIndex;
}
if (globalSearchIndexPromise) return globalSearchIndexPromise;
globalSearchIndexPromise = (async () => {
const index = await buildGlobalSearchIndex();
globalSearchIndex = index;
globalSearchIndexAt = Date.now();
return index;
})();
try {
return await globalSearchIndexPromise;
} finally {
globalSearchIndexPromise = null;
}
}
async function buildGlobalSearchIndex() {
let courses = [];
try {
// enrollment_state=active excludes concluded/inactive enrollments at the
// source so we never index (or waste requests on) past-term courses.
courses = await getData(`${domain}/api/v1/courses?enrollment_state=active&per_page=100`);
} catch (e) {
console.warn("[CanvasRefined] global search: failed to load courses", e);
return [];
}
if (!Array.isArray(courses) || !courses.length) return [];
// Skip inactive/concluded/hidden courses. `enrollment_state=active`
// already filters at the source, but some institutions return past-term
// courses as "active", so we double-check here:
// - access_restricted_by_date (locked courses)
// - concluded === true (Canvas marks concluded courses)
// - term end_date in the past (when the API exposes it)
// - courses the user hid from their dashboard (custom_cards.hidden)
const now = Date.now();
courses = courses.filter(c => {
if (!c || !c.name) return false;
if (c.access_restricted_by_date === true) return false;
if (c.concluded === true) return false;
// term end date check (API may return term.end_at)
const endAt = c?.term?.end_at || c?.end_at;
if (endAt) {
const end = new Date(endAt).getTime();
if (!isNaN(end) && end < now) return false;
}
if (isCourseHidden(c.id)) return false;
return true;
});
// Cap to keep request volume sane.
courses = courses.slice(0, 60);
// Canvas can return the same course more than once (multi-role enrollments,
// cross-listed sections). Dedupe by id so we don't double-index or double-fetch.
const seenCourseIds = new Set();
courses = courses.filter(c => {
if (seenCourseIds.has(c.id)) return false;
seenCourseIds.add(c.id);
return true;
});
const index = [];
// Shared dedup state. Keys identify a piece of *content* regardless of where
// it surfaced, so the same assignment (which Canvas exposes both as a module
// item AND a standalone assignment) collapses to a single result.
// - standalone assignment: `asn:<courseId>:<assignmentId>`
// - module item with content_id: `<type>:<courseId>:<contentId>`
// (for type "assignment" this becomes `asn:<courseId>:<contentId>` —
// the SAME key as the standalone assignment, so whichever is added
// first wins; we add assignments first to keep the direct URL)
// - module item without content_id (external url/tool): `url:<normalizedUrl>`
// - module itself: `module:<courseId>:<moduleId>`
const seenContent = new Set();
await Promise.all(courses.map(async (course) => {
const courseId = course.id;
const courseName = course.name;
const courseCode = course.course_code || courseName;
// Assignments first so their direct URLs win over the module-item
// versions of the same assignment.
try {
const assignments = await getData(`${domain}/api/v1/courses/${courseId}/assignments?per_page=100`);
if (Array.isArray(assignments)) {
for (const a of assignments) {
if (!a || !a.name || !a.html_url) continue;
const key = `asn:${courseId}:${a.id}`;
if (seenContent.has(key)) continue;
seenContent.add(key);
index.push({
type: "Assignment",
title: a.name,
course: courseName,
courseCode,
courseId,
url: a.html_url
});
}
}
} catch (_) { /* non-fatal */ }
// Modules + their items.
try {
const modules = await getData(`${domain}/api/v1/courses/${courseId}/modules?per_page=100`);
if (Array.isArray(modules)) {
for (const m of modules) {
if (!m || !m.name) continue;
const modKey = `module:${courseId}:${m.id}`;
if (!seenContent.has(modKey)) {
seenContent.add(modKey);
index.push({
type: "Module",
title: m.name,
course: courseName,
courseCode,
courseId,
url: `${domain}/courses/${courseId}/modules`
});
}
try {
const items = await getData(`${domain}/api/v1/courses/${courseId}/modules/${m.id}/items?per_page=100`);
if (Array.isArray(items)) {
for (const it of items) {
if (!it || !it.title) continue;
// Skip text headers / dividers — no destination page.
const itype = (it.type || "").toLowerCase();
if (itype === "subheader") continue;
// Prefer the real page link (html_url). External
// URL items expose external_url instead; the bare
// `url` field is the API endpoint, never use it.
const url = it.html_url || it.external_url;
if (!url) continue;
// Build a content-identity key so the same item
// appearing in multiple modules (or mirroring a
// standalone assignment) only produces one result.
let key;
if (itype === "assignment" && it.content_id) {
key = `asn:${courseId}:${it.content_id}`;
} else if (it.content_id) {
key = `${itype}:${courseId}:${it.content_id}`;
} else {
key = `url:${normalizeGlobalSearchUrl(url)}`;
}
if (seenContent.has(key)) continue;
seenContent.add(key);
index.push({
type: prettyModuleItemType(it.type),
title: it.title,
course: courseName,
courseCode,
courseId,
url
});
}
}
} catch (_) { /* per-module failure is non-fatal */ }
}
}
} catch (_) { /* per-course failure is non-fatal */ }
}));
// Final safety net: collapse any remaining normalized-URL duplicates (e.g.
// external links whose content_id differed but resolve to the same page).
const seen = new Set();
return index.filter(item => {
const key = normalizeGlobalSearchUrl(item.url);
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
}
function normalizeGlobalSearchUrl(url) {
if (!url) return "";
try {
const u = new URL(url, domain);
let path = u.pathname.replace(/\/+$/, ""); // strip trailing slashes
// Ignore case + fragment/query for matching purposes.
return (u.host.toLowerCase() + path).toLowerCase();
} catch (_) {
// Non-absolute (shouldn't happen, but be safe) — normalize as-is.
return String(url).replace(/\/+$/, "").toLowerCase();
}
}
function prettyModuleItemType(type) {
switch ((type || "").toLowerCase()) {
case "assignment": return "Assignment";
case "quiz": return "Quiz";
case "discussion": case "discussion_topic": return "Discussion";
case "externalurl": return "Link";
case "externaltool": return "External Tool";
case "file": return "File";
case "page": return "Page";
case "subheader": return "Section";
default: return type || "Item";
}
}
// --- Searching --------------------------------------------------------------
async function runGlobalSearch(query, resultsEl) {
if (!globalSearchIndex && globalSearchIndexPromise) {
resultsEl.innerHTML = `<div class="canvasrefined-gs-loading">Building search index\u2026</div>`;
}
const index = await ensureGlobalSearchIndex();
if (!query) {
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">Start typing to search your modules and assignments.</div>`;
return [];
}
if (!index || !index.length) {
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No modules or assignments found. Open the search again later if your courses are still loading.</div>`;
return [];
}
const q = query.toLowerCase();
const matches = [];
for (const item of index) {
// Re-check hidden status at search time so a card hidden after the index
// was cached (10-min TTL) never surfaces in results.
if (isCourseHidden(item.courseId)) continue;
const t = (item.title || "").toLowerCase();
const c = (item.course || "").toLowerCase();
let score = -1;
if (t.startsWith(q)) score = 100 - t.indexOf(q);
else if (t.includes(q)) score = 60 - t.indexOf(q);
else if (c.includes(q)) score = 20;
if (score >= 0) { item._score = score + (t === q ? 50 : 0); matches.push(item); }
}
matches.sort((a, b) => b._score - a._score);
const top = matches.slice(0, 50);
if (!top.length) {
resultsEl.innerHTML = `<div class="canvasrefined-gs-hint">No results for \u201c${escapeGlobalSearchHtml(query)}\u201d.</div>`;
return [];
}
resultsEl.innerHTML = top.map((item, i) => `
<div class="canvasrefined-gs-row" data-i="${i}" data-url="${escapeGlobalSearchAttr(item.url)}">
<div class="canvasrefined-gs-row-main">
<span class="canvasrefined-gs-type canvasrefined-gs-type-${escapeGlobalSearchAttr((item.type || "").toLowerCase().replace(/\s+/g, "-"))}">${escapeGlobalSearchHtml(item.type || "")}</span>
<span class="canvasrefined-gs-title">${escapeGlobalSearchHtml(item.title || "")}</span>
</div>
<span class="canvasrefined-gs-course">${escapeGlobalSearchHtml(item.course || "")}</span>
</div>`).join("");
resultsEl.querySelectorAll(".canvasrefined-gs-row").forEach((row) => {
// Plain click / Ctrl+click: honor modifier for new-tab behavior.
row.addEventListener("click", (e) => {
const url = row.getAttribute("data-url");
const item = top.find(x => x.url === url);
if (item) openGlobalSearchResult(item, e.ctrlKey || e.metaKey || (e.button === 1));
});
// Middle-click opens in a new tab without closing the search.
row.addEventListener("auxclick", (e) => {
if (e.button !== 1) return;
e.preventDefault();
const url = row.getAttribute("data-url");
const item = top.find(x => x.url === url);
if (item) openGlobalSearchResult(item, true);
});
});
return top;
}
function escapeGlobalSearchHtml(s) {
return String(s).replace(/[&<>"']/g, ch => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]));
}
function escapeGlobalSearchAttr(s) {
return escapeGlobalSearchHtml(s).replace(/`/g, "&#96;");
}
function changeGradientCards() {
if (options.gradient_cards === true) {
let cardheads = document.querySelectorAll('.ic-DashboardCard__header_hero');

View File

@ -1,4 +1,4 @@
const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards', 'quiz_safe_mode'];
const syncedSwitches = ['remind', 'tab_icons', 'dark_mode', 'remlogo', 'full_width', 'auto_dark', 'assignments_due', 'gpa_calc', 'gradient_cards', 'disable_color_overlay', 'dashboard_grades', 'dashboard_notes', 'better_todo', 'better_sidebar', 'condensed_cards', 'hide_new_canvas', 'center_cards', 'quiz_safe_mode', 'global_search'];
const syncedSubOptions = [
"todo_hide_feedback",
"todo_full_height",