// ==UserScript== // @name Compass QoL Enhancer // @namespace blankie-scripts // @match http*://*.compass.education/* // @version 1.14.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; let qolLearningTaskOpened = false; // needed because .toString() adds a trailing = for empty values URLSearchParams.prototype.laxToString = function() { let out = ""; for (let [key, value] of this) { if (out) { out += "&"; } // required for session/... or activity/... out += encodeURIComponent(key).replaceAll("%2F", "/"); if (value) { out += `=${encodeURIComponent(value)}`; } } return out; } function getHashSearch() { return new URLSearchParams(window.location.hash.substring(1)); } function getExtClass(className) { if (!unsafeWindow.Ext) { return null; } return unsafeWindow.Ext.ClassManager.get(className); } function getPanelItemHash(panelId, isDefault) { if (window.location.pathname === "/Organise/Activities/Activity.aspx") { let query = getHashSearch(); 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"); } query.delete("qol_learning_task"); return `#${query.laxToString()}`; } return `#${encodeURIComponent(panelId)}`; } // Prevent the dashboard tab in the user page being "dsh" let UserProfileNewWidget = getExtClass("Compass.widgets.UserProfileNewWidget"); if (UserProfileNewWidget) { let original = UserProfileNewWidget.prototype.createItems; UserProfileNewWidget.prototype.createItems = function() { let res = original.apply(this, arguments); delete res[0].listeners; return res; }; } // Automatically open learning tasks specified in the URL let LearningTasksDetailsWidgetNew = getExtClass("Compass.widgets.LearningTasksDetailsWidgetNew"); if (LearningTasksDetailsWidgetNew) { let original = LearningTasksDetailsWidgetNew.prototype.forceOpenLearningTaskOnLoad; LearningTasksDetailsWidgetNew.prototype.forceOpenLearningTaskOnLoad = function() { // stole this from the function above let toOpen = this.initialLoadComplete && !this.firstLoad; let exit = !toOpen || this.openLearningTaskId || qolLearningTaskOpened; original.apply(this, arguments); if (exit) { return; } qolLearningTaskOpened = true; let learningTask = parseInt(getHashSearch().get("qol_learning_task"), 10); if (isNaN(learningTask)) { return; } let learningTaskRecord = this.taskStore.findRecord("id", learningTask); if (!learningTaskRecord) { return; } this.launchLearningTasksSubmissionWidget(learningTaskRecord.data.gridRecordId, false); }; } // Automatically add (and remove) the currently open learning task to the URL function handleLearningTaskOpen(window) { let search = getHashSearch(); search.set("qol_learning_task", window.learningTask.data.id); history.pushState("", "", `#${search.laxToString()}`); } function handleLearningTaskClose() { let search = getHashSearch(); search.delete("qol_learning_task"); history.pushState("", "", `#${search.laxToString()}`); } let LearningTasksSubmissionWidget = getExtClass("Compass.widgets.LearningTasksSubmissionWidget"); if (LearningTasksSubmissionWidget) { let original = LearningTasksSubmissionWidget.prototype.initComponent; LearningTasksSubmissionWidget.prototype.initComponent = function() { original.apply(this, arguments); handleLearningTaskOpen(this); this.on("close", handleLearningTaskClose); } } function updateInstanceButton(instanceDetails, instanceButton, offset) { // Make previous/next session buttons links let index = instanceDetails.instanceStore.indexOfId(instanceDetails.m_instanceId); index += offset; if (index >= 0 && index < instanceDetails.instanceStore.count()) { let url = `#session/${instanceDetails.instanceStore.getAt(index).internalId}`; instanceButton.el.dom.href = url; } else { instanceButton.el.dom.removeAttribute("href"); } } let InstanceDetailsWidget = getExtClass("Compass.widgets.InstanceDetailsWidget"); if (InstanceDetailsWidget) { let originalUpdateInstanceHeader = InstanceDetailsWidget.prototype.updateInstanceHeader; InstanceDetailsWidget.prototype.updateInstanceHeader = function() { originalUpdateInstanceHeader.apply(this, arguments); let toolbar = this.getInstanceNavigatorToolbar(); updateInstanceButton(this, toolbar.getComponent("previousInstanceButton"), -1); updateInstanceButton(this, toolbar.getComponent("nextInstanceButton"), 1); }; let originalGetInstanceNavigatorToolbar = InstanceDetailsWidget.prototype.getInstanceNavigatorToolbar; InstanceDetailsWidget.prototype.getInstanceNavigatorToolbar = function() { if (this.instanceNavigatorToolbar) { return this.instanceNavigatorToolbar; } let toolbar = originalGetInstanceNavigatorToolbar.apply(this, arguments); let previousButton = toolbar.getComponent("previousInstanceButton"); let nextButton = toolbar.getComponent("nextInstanceButton"); previousButton.preventDefault = false; let originalPreviousButtonHandler = previousButton.handler; previousButton.handler = function(_, event) { if (!event.ctrlKey) { event.preventDefault(); originalPreviousButtonHandler.apply(this, arguments); } }; nextButton.preventDefault = false; let originalNextButtonHandler = nextButton.handler; nextButton.handler = function(_, event) { if (!event.ctrlKey) { event.preventDefault(); originalNextButtonHandler.apply(this, arguments); } }; return toolbar; }; // not a typo :) let originalGetCmbSelectIntance = InstanceDetailsWidget.prototype.getCmbSelectIntance; InstanceDetailsWidget.prototype.getCmbSelectIntance = function() { if (this.cmbSelectInstance) { return this.cmbSelectInstance; } let combo = originalGetCmbSelectIntance.apply(this, arguments); // Make sessions in the sessions dropdown links combo.tpl.html = combo.tpl.html.replace( /
(.+?)<\/div>/, "$3" ); return combo; }; } function handleNewCalendarEvent(element, calendar) { // 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 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}`; } 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}`; } } else if (a.classList.contains("activity-type-10")) { // Add a link for learning tasks let calendarPanel = calendar.ownerCalendarPanel; a.href = "/Records/User.aspx" if (calendarPanel.targetUserId !== undefined) { a.href += `?userId=${calendarPanel.targetUserId}`; } // Link specifically to the learning task a.href += `#learningTasks&qol_learning_task=${calendarData.learningTaskId}`; } // 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(".ext-evt-bd") || 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 (a.href) { a.addEventListener("click", function(event) { event.stopImmediatePropagation(); }, {passive: true}); } element.replaceWith(a); } function handlePanelItem(panel, panelItem, isDefault, tabToOpen) { let panelId = panelItem.itemId || panelItem.id; // example of panel-: /Organise/Subjects/Subject.aspx if (panelId.startsWith("panel-")) { panelId = panelItem.title.toLowerCase().replaceAll(" ", "-"); } let panelItemHash = getPanelItemHash(panelId, isDefault); // 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, // hence this hook to open it when the dashboard finishes loading if (panel.setDashboardToLoading) { let original = panel.setDashboardToLoading; panel.setDashboardToLoading = function(loading) { original.apply(this, arguments); !loading && panel.setActiveTab(panelItem); } } else { panel.setActiveTab(panelItem); } } // /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 (window.location.hash !== panelItemHash && (window.location.pathname === "/Organise/Activities/Activity.aspx" || !window.location.hash.startsWith(`${panelItemHash}&`))) { // 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}); } // Add links to learning tasks themselves let gridView = Ext.getCmp(element.parentElement.id); for (let item of gridView.store.data.items) { let data = item.data; let a = element.querySelector(`[gridrecordid="${data.gridRecordId}"]`); let query = getHashSearch(); query.set("qol_learning_task", data.id); a.href = `#${query.laxToString()}`; a.addEventListener("click", function(event) { if (event.ctrlKey) { event.stopImmediatePropagation(); } else { event.preventDefault(); } }); } } 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"))) { let calendar = unsafeWindow.Ext.getCmp(node.closest(".x-component").id); if (calendar.view) { calendar = calendar.view; } for (let element of node.querySelectorAll("div.ext-cal-evt")) { handleNewCalendarEvent(element, calendar); } } else if (!qolTabOpened && node.classList.contains("x-panel")) { qolTabOpened = true; let topMostPanel = node; while (topMostPanel.parentElement.closest(".x-panel")) { topMostPanel = topMostPanel.parentElement.closest(".x-panel"); } let panel = unsafeWindow.Ext.getCmp(topMostPanel.id); let tabToOpen = window.location.pathname === "/Organise/Activities/Activity.aspx" ? getHashSearch().get("qol_open_tab") : (window.location.hash.replace(/^#(.+?)(?:&.*)?$/, "$1") || null); 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(); } else if (event.target.closest(".newsfeed-newsItem") && !event.target.closest("a")) { // Expand news feed items by clicking on them // the check is to prevent expending by clicking on a link (say, to a file) let seeMore = event.target.closest(".newsfeed-newsItem").querySelector(".newsfeed-newsItem-seeMoreCTA a"); if (seeMore) { seeMore.click(); } } }, {passive: true}); // Stop the calendar and email buttons from opening in a new tab for (let element of document.querySelectorAll("#productNavBar a[target='_blank']")) { element.removeAttribute("target"); } // 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; }