// ==UserScript== // @name Compass QoL Enhancer // @namespace blankie-scripts // @match http*://*.compass.education/* // @version 1.9.0 // @author blankie // @description A userscript that adds small but useful features for Compass, such as the ability to close windows by clicking on the background // @inject-into page // @run-at document-end // ==/UserScript== "use strict"; let qolTabOpened = false; // we make a copy of window.location.hash because the dashboard tab on a user's page would set the fragment to #dsh for some reason let hashCopy = window.location.hash; // needed because .toString() adds a trailing = for empty values function serializeURLSearchParams(query) { let out = ""; for (let [key, value] of query) { if (out) { out += "&"; } // required for session/... or activity/... out += encodeURIComponent(key).replaceAll("%2F", "/"); if (value) { out += `=${encodeURIComponent(value)}`; } } return out; } function getPanelItemHash(panelId, isDefault) { if (window.location.pathname === "/Organise/Activities/Activity.aspx") { let query = new URLSearchParams(window.location.hash.substring(1)); if (panelId === "learningtasks") { query.delete("qol_open_tab"); query.set("openLearningTaskTab", ""); } else if (isDefault) { query.delete("qol_open_tab"); query.delete("openLearningTaskTab"); } else { query.set("qol_open_tab", panelId); query.delete("openLearningTaskTab"); } return `#${serializeURLSearchParams(query)}`; } return `#${encodeURIComponent(panelId)}`; } function handleNewCalendarEvent(element) { // Turn each calendar event into a link so that Link Hints can recognize it let a = document.createElement("a"); for (let attr of element.attributes) { a.setAttribute(attr.name, attr.value); } // fix learning tasks shrinking for some reason a.style.display = "block"; // fix weird aboveline (underline but above the text) on hover a.style.textDecoration = "none"; a.replaceChildren(...element.childNodes); let preventCompassHandler = false; let calendar = unsafeWindow.Ext.getCmp(element.closest(".x-component").id); if (calendar.view) { calendar = calendar.view; } let calendarData = calendar.getEventRecordFromEl(element).data; if (a.classList.contains("activity-type-1")) { // Add a link for activities/"standard classes" a.href = `/Organise/Activities/Activity.aspx?targetUserId=${calendarData.targetStudentId}#session/${calendarData.instanceId}`; preventCompassHandler = true; } else if (a.classList.contains("activity-type-2")) { // Add a link for action centre events a.href = `/Organise/Activities/Events/Event.aspx?eventId=${calendarData.activityId}`; if (calendarData.targetStudentId) { a.href += `&userId=${calendarData.targetStudentId}`; } preventCompassHandler = true; } else if (a.classList.contains("activity-type-10")) { // Add a link for learning tasks let calendarPanel = calendar.ownerCalendarPanel; a.href = calendarPanel.targetUserId !== undefined ? `/Records/User.aspx?userId=${calendarPanel.targetUserId}#learningTasks` : "/Records/User.aspx#learningTasks"; preventCompassHandler = true; } // Show the finish time for applicable calendar events let startString = unsafeWindow.Ext.util.Format.date(calendarData.start, unsafeWindow.Compass.TIME_NO_PERIOD_FORMAT); let finishString = unsafeWindow.Ext.util.Format.date(calendarData.finish, unsafeWindow.Compass.TIME_NO_PERIOD_FORMAT); let textElement = a.querySelector("span") || a; // yes, innerHTML. longTitleWithoutTime can apparently contain HTML. lets hope that startString and finishString don't if (textElement.innerHTML === `${startString}: ${calendarData.longTitleWithoutTime}`) { textElement.innerHTML = `${startString} - ${finishString}: ${calendarData.longTitleWithoutTime}`; } // prevent ctrl-clicking from changing the current tab // it seems like the link thing actually effectively killed the default handler anyway if (preventCompassHandler) { a.addEventListener("click", function(event) { event.stopImmediatePropagation(); }, {passive: true}); } element.replaceWith(a); } function handlePanelItem(panel, panelItem, isDefault, tabToOpen) { let panelId = panelItem.itemId || panelItem.id; let panelItemHash = getPanelItemHash(panelId, isDefault); // i don't know why but dashboard is dsh if (window.location.pathname === "/Records/User.aspx" && panelId === "dashboard") { panelId = "dsh"; } // Automatically open tab specified in fragment if (panelId === tabToOpen) { // if the sessions tab is automatically opened, the currently selected tab is still dashboard for some reason // i can't be arsed to read any more minified javascript, so this is the best fix that you'll get // it does look like that this bug only manifests if the tab is activated while the initial loading thing is shown setTimeout(function() { panel.setActiveTab(panelItem); // reference the now active tab in the fragment if necessary (cough cough the reason why hashCopy exists) if (panelItemHash !== window.location.hash) { history.replaceState("", "", panelItemHash); } }, 1000); } // /Communicate/SchoolDocumentation.aspx does not have tabs if (!panelItem.tab) { return; } panelItem.tab.preventDefault = false; panelItem.tab.el.dom.href = panelItemHash; panelItem.tab.el.dom.addEventListener("click", function(event) { if (event.ctrlKey) { event.stopImmediatePropagation(); return; } // prevent the browser from scrolling to the body event.preventDefault(); if (panelItemHash !== window.location.hash) { // Automatically add a reference to the tab when it is clicked history.pushState("", "", panelItemHash); } }); } function handleLearningTasksTable(element) { // Make ctrl-clicking activities in a user's learning tasks tab no longer collapse everything else for (let a of element.querySelectorAll(".x-grid-group-title a")) { a.addEventListener("click", function(event) { event.stopImmediatePropagation(); }, {passive: true}); } } function handleCKEditor(instance) { instance.on("contentDom", function() { let editable = instance.editable(); for (let element of editable.$.querySelectorAll("a")) { handleCKEditorLink(element); } observer.observe(editable.$, {childList: true, subtree: true}); }); } function handleCKEditorLink(element) { // Make links inside lesson plans open in the parent instead of a new tab if (element.target !== "_blank" && element.target) { return; } element.target = "_parent"; element.addEventListener("click", function(event) { event.stopImmediatePropagation(); }, {passive: true}); } function handleNewNode(node, observer) { if (node.nodeType !== 1) { return; } if (node.parentElement && (node.classList.contains("ext-cal-hd-ct") || node.classList.contains("ext-cal-bg-tbl") || node.classList.contains("ext-cal-inner-ct") || node.classList.contains("ext-cal-mdv"))) { for (let element of node.querySelectorAll("div.ext-cal-evt")) { handleNewCalendarEvent(element); } } else if (!qolTabOpened && node.classList.contains("x-panel")) { qolTabOpened = true; let tabToOpen = hashCopy.substring(1) || null; if (window.location.pathname === "/Organise/Activities/Activity.aspx") { tabToOpen = (new URLSearchParams(tabToOpen)).get("qol_open_tab"); } let panel = unsafeWindow.Ext.getCmp(node.id); for (let i = 0; i < panel.items.items.length; i++) { handlePanelItem(panel, panel.items.items[i], i === 0, tabToOpen); } } else if (node.closest("[id^='wikibrowserpanel-'], #CompassWidgetsWikiBrowserPanel")) { // Make files and folders in wiki/resources clickable for Link Hints if (node.localName === "td") { node.setAttribute("role", "button"); } for (let element of node.querySelectorAll("td, .x-tree-expander")) { element.setAttribute("role", "button"); } } else if (node.localName === "table" && node.closest(".sel-learning-tasks-widget")) { handleLearningTasksTable(node); } else if (node.classList.contains("cke")) { let instance = unsafeWindow.CKEDITOR.instances[node.id.substring(4)]; handleCKEditor(instance); } else if (node.localName === "a" && /\bcke_/.test(node.closest("body").className)) { handleCKEditorLink(node); } } let observer = new MutationObserver(function(mutations, observer) { for (let mutation of mutations) { if (mutation.type !== "childList") { continue; } for (let node of mutation.addedNodes) { handleNewNode(node, observer); } } }); observer.observe(document.body, {childList: true, subtree: true}); document.body.addEventListener("click", function(event) { if (event.target.classList.contains("x-mask")) { // Add the ability to close windows by clicking on the background let maskZIndex = BigInt(event.target.style.zIndex); for (let maskMsg of document.querySelectorAll(".x-mask-msg")) { if (BigInt(maskMsg.style.zIndex) >= maskZIndex && maskMsg.style.display !== "none") { return; } } let closeButton = document.querySelector(".x-window-closable.x-window-active .x-tool-close"); if (closeButton) { closeButton.click(); } } else if (event.target.classList.contains("x-form-file-btn")) { // Make Link Hints work with uploading files event.target.querySelector("input[type=file]").click(); } }, {passive: true}); let style = document.createElement("style"); style.textContent = ` /* Make strikethroughs more noticeable on calendar events and their tooltips */ .ext-cal-evt s, .calendar-tip .title s { text-decoration-thickness: .25em; } `; document.head.append(style); // Suppress that annoying barebones context menu that only has Copy // why is it not obvious that you can just hold ctrl or shift to suppress it??? // i know it's compass' fault but i'm still frustrated // unsafeWindow.CKEDITOR may not be set if you're on the login page, for example if (unsafeWindow.CKEDITOR) { let originalOn = unsafeWindow.CKEDITOR.dom.domObject.prototype.on; unsafeWindow.CKEDITOR.dom.domObject.prototype.on = function(type, listener) { // https://stackoverflow.com/a/10815581 if (type !== "contextmenu") { return originalOn.apply(this, arguments); } }; } // Uncheck the Remember Me button by default if (window.location.pathname === "/login.aspx") { document.querySelector("#rememberMeChk").checked = false; }