// ==UserScript== // @name Compass QoL Enhancer // @namespace blankie-scripts // @match http*://*.compass.education/* // @version 1.21.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; let modifyUrlHash = true; let shownWindows = 0; // 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 scrolling if a window is open function handleWindowShow() { shownWindows++; document.documentElement.style.overflow = "hidden"; } function handleWindowClose() { shownWindows--; if (shownWindows <= 0) { document.documentElement.style.overflow = ""; } } let Window = getExtClass("Ext.window.Window"); if (Window) { let original = Window.prototype.initComponent; Window.prototype.initComponent = function() { original.apply(this, arguments); this.on("show", handleWindowShow); this.on("close", handleWindowClose); }; } // 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; }; } function handleNewsItemClick(event) { if (!this.moreButton.hidden && event.target.localName !== "a") { Compass.saveScrollPosition(); this.expandTruncated(); } } let NewsfeedItemWidget = getExtClass("Compass.widgets.NewsfeedItemWidget"); if (NewsfeedItemWidget) { let original = NewsfeedItemWidget.prototype.renderLayout; NewsfeedItemWidget.prototype.renderLayout = function() { original.apply(this, arguments); // prevent clicking on text from expanding the item since this disrupts selecting text, and we're gonna extend this to the entire item anyway this.newsItemTextContainer.events.afterrender = true; // Expand news feed items by clicking on them this.on("afterrender", function() { this.el.on("click", handleNewsItemClick.bind(this), this.el); }); } } // Make links in Looking Ahead actual links let NewsfeedAlertItemWidget = getExtClass("Compass.widgets.NewsfeedAlertItemWidget"); if (NewsfeedAlertItemWidget) { let original = NewsfeedAlertItemWidget.prototype.initComponent; NewsfeedAlertItemWidget.prototype.initComponent = function() { this.alertItem.Content = this.alertItem.Content.replaceAll( /= 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; }; } // Sort by name in wiki/resources function wikiNodeSort(lhs, rhs) { function isFolder(node) { return node.data.type === unsafeWindow.Compass.enums.WikiNodeType.Folder; } if (isFolder(lhs) && !isFolder(rhs)) { return -1; } else if (!isFolder(lhs) && isFolder(rhs)) { return 1; } let lhsName = lhs.data.name.toLowerCase(); let rhsName = rhs.data.name.toLowerCase(); if (lhsName < rhsName) { return -1; } else if (lhsName === rhsName) { return 0; } else { return 1; } } let WikiBrowserPanel = getExtClass("Compass.widgets.WikiBrowserPanel"); if (WikiBrowserPanel) { let originalInitComponent = WikiBrowserPanel.prototype.initComponent; WikiBrowserPanel.prototype.initComponent = function() { this.on("treeDataLoaded", function() { this.treePanel.store.sort({sorterFn: wikiNodeSort}); }); originalInitComponent.apply(this, arguments); } let originalRenderLayout = WikiBrowserPanel.prototype.renderLayout; WikiBrowserPanel.prototype.renderLayout = function() { originalRenderLayout.apply(this, arguments); try { this.treePanel.store.sort({sorterFn: wikiNodeSort}); } catch (e) { // /Communicate/SchoolDocumentation.aspx dies for some reason } }; } 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}`; // Hide user's last name on learning tasks calendarData.longTitleWithoutTime = calendarData.longTitleWithoutTime.replace(/^.+?, /, ""); } // 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 handlePanel(panel) { 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); } } 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 (!qolTabOpened && 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 (modifyUrlHash && 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(); } }); } } // Make links submitted to a learning task actual links function handleLearningTaskSubmissionTable(element) { let items = unsafeWindow.Ext.getCmp(element.id).store.data.items; let lastColumns = element.querySelectorAll(".x-grid-cell-last img"); for (let i = 0; i < items.length; i++) { let item = items[i].data; let img = lastColumns[i]; if (item.submissionFileType !== unsafeWindow.Compass.enums.LearningTasksSubmissionFileType.SubmissionUrl) { continue; } let a = document.createElement("a"); a.href = item.fileName; a.addEventListener("click", function(event) { event.stopImmediatePropagation(); }, {passive: true}); img.replaceWith(a); a.append(img); } } 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 === "_parent") { 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")) { let topMostPanel = node; while (topMostPanel.parentElement.closest(".x-panel")) { topMostPanel = topMostPanel.parentElement.closest(".x-panel"); } handlePanel(unsafeWindow.Ext.getCmp(topMostPanel.id)); qolTabOpened = true; } 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("x-grid-view") && unsafeWindow.Ext.getCmp(node.id).up("#submissionsPanel")) { handleLearningTaskSubmissionTable(node); } 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}); // Allow submitting links by pressing Enter instead of clicking the button document.body.addEventListener("keydown", function(event) { if (event.key !== "Enter" || event.target.localName !== "input" || event.target.type !== "text") { return; } let window = unsafeWindow.Ext.getCmp(event.target.closest(".x-window").id); if (!window || window.itemId !== "urlSelectionModal") { return; } window.down("#saveButton").handler(); }, {passive: true}); // Re-open panel tabs and learning tasks when the URL changes function getLearningTaskWindow() { let LearningTasksSubmissionWidget = getExtClass("Compass.widgets.LearningTasksSubmissionWidget"); for (let window of document.querySelectorAll(".x-window")) { window = unsafeWindow.Ext.getCmp(window.id); if (unsafeWindow.Ext.getClass(window) === LearningTasksSubmissionWidget) { return window; } } return null; } function getLearningTaskTab(panel) { return panel.items.items.find(function(tab) { return tab.itemId === "learningtasks"; }); } window.addEventListener("hashchange", function(event) { modifyUrlHash = false; let isActivityPage = window.location.pathname === "/Organise/Activities/Activity.aspx"; let hashSearch = getHashSearch(); let panel = document.querySelector(".x-panel"); if (panel) { panel = unsafeWindow.Ext.getCmp(panel.id); if (isActivityPage && hashSearch.has("openLearningTaskTab")) { panel.setActiveTab(getLearningTaskTab(panel)); } else if (isActivityPage && hashSearch.size === 1) { event.stopImmediatePropagation(); panel.setActiveTab(panel.items.items[0]); } else { handlePanel(panel); } } let learningTaskWindow = getLearningTaskWindow(); if (learningTaskWindow) { learningTaskWindow.close(); } let learningTaskWidget = document.querySelector(".sel-learning-tasks-widget"); if (learningTaskWidget) { unsafeWindow.Ext.getCmp(learningTaskWidget.id).openUrlLearningTask(); } modifyUrlHash = 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"); } // unsafeWindow.CKEDITOR may not be set if you're on the login page, for example if (unsafeWindow.CKEDITOR) { // Suppress that annoying barebones context menu that only has Copy unsafeWindow.CKEDITOR.plugins.load("contextmenu", function() { unsafeWindow.CKEDITOR.plugins.contextMenu.prototype.addTarget = function() {}; }); // Workaround https://github.com/lydell/LinkHints/issues/86 CKEDITOR.dom.document.prototype.write = function(data) { this.$.documentElement.innerHTML = data; for (let script of this.$.documentElement.querySelectorAll("script")) { // script.cloneNode() makes it not execute for some reason let scriptClone = document.createElement("script"); for (let attr of script.attributes) { scriptClone.setAttribute(attr.name, attr.value); } scriptClone.innerText = script.innerText; script.replaceWith(scriptClone); } this.$.dispatchEvent(new Event("DOMContentLoaded")); }; } // Uncheck the Remember Me button by default if (window.location.pathname === "/login.aspx") { document.querySelector("#rememberMeChk").checked = false; }