// ==UserScript== // @name Compass QoL Enhancer // @namespace blankie-scripts // @match http*://*.compass.education/* // @version 1.26.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; // popups should bot be blocked by the prioritised nav bar let productNavBar = window.location.pathname !== "/ActionCentre/" ? document.querySelector("#productNavBar") : null; // 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, and put the nav bar over or behind masks when necessary function handleWindowShow() { shownWindows++; document.documentElement.style.overflow = "hidden"; productNavBar.style.zIndex = ""; } function handleWindowClose() { shownWindows--; if (shownWindows <= 0) { document.documentElement.style.overflow = ""; productNavBar.style.zIndex = 20000; } } 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); }; } // example of message boxes: remove device on /Configure/LoginAndSecurity.aspx // for some reason, listening on Ext.window.MessageBox does nothing let Msg = unsafeWindow.Ext ? unsafeWindow.Ext.Msg : null; if (Msg) { let original = Msg.show; Msg.show = function(options) { let originalFn = options.fn; options.fn = function() { handleWindowClose(); if (originalFn) { originalFn.apply(this, arguments); } }; handleWindowShow(); original.apply(this, arguments); }; } if (productNavBar) { productNavBar.style.zIndex = 20000; } let UserProfileNewWidget = getExtClass("Compass.widgets.UserProfileNewWidget"); if (UserProfileNewWidget) { // Prevent the dashboard tab in the user page being "dsh" let original = UserProfileNewWidget.prototype.createItems; UserProfileNewWidget.prototype.createItems = function() { let res = original.apply(this, arguments); delete res[0].listeners; return res; }; // Prevent clicking on a learning task from changing back to the Dashboard tab UserProfileNewWidget.prototype.processHashChange = function() {}; } function handleNewsItemClick(event) { if (!this.moreButton.hidden && !event.target.closest("a, .newsfeed-image")) { 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( /") + 1, 0, refreshButton); }; } // Automatically add (and remove) the currently open learning task to the URL function handleLearningTaskShow() { if (!modifyUrlHash) { return; } let search = getHashSearch(); search.set("qol_learning_task", this.learningTask.data.id); window.location.hash = `#${search.laxToString()}`; } function handleLearningTaskClose() { if (!modifyUrlHash) { return; } let search = getHashSearch(); search.delete("qol_learning_task"); window.location.hash = `#${search.laxToString()}`; } let LearningTasksSubmissionWidget = getExtClass("Compass.widgets.LearningTasksSubmissionWidget"); if (LearningTasksSubmissionWidget) { let original = LearningTasksSubmissionWidget.prototype.initComponent; LearningTasksSubmissionWidget.prototype.initComponent = function() { original.apply(this, arguments); this.on("show", handleLearningTaskShow.bind(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) { // recreation of updateURLHash to fix qol_learning_task being stripped // example: /Organise/Activities/Activity.aspx#activity/67672&openLearningTaskTab&qol_learning_task=77334 InstanceDetailsWidget.prototype.updateURLHash = function(useInstanceId) { let match = window.location.hash.match(/^#.*?[?&](.+)$/); let hash = useInstanceId ? `session/${this.m_instanceId}` : `activity/${this.m_activityId}`; if (match) { hash += `&${match[1]}`; } window.location.hash = `#${hash}`; }; 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() { // Reveal filter and refresh toolbars let readOnly = this.readOnly; this.readOnly = false; originalRenderLayout.apply(this, arguments); this.readOnly = readOnly; // hide tools button and context menu since it is useless to us this.treePanel.down("#toolButton").hidden = true; this.treePanel.events.itemcontextmenu = true; // check if root node exists, otherwise pages like /Communicate/SchoolDocumentation.aspx will die if (this.treePanel.getRootNode()) { this.treePanel.store.sort({sorterFn: wikiNodeSort}); } }; let originalSelectedNodeChanged = WikiBrowserPanel.prototype.selectedNodeChanged; WikiBrowserPanel.prototype.selectedNodeChanged = function() { // Reveal "View Permissions", "Visible to", and "Created by" let readOnly = this.readOnly; this.readOnly = false; originalSelectedNodeChanged.apply(this, arguments); this.readOnly = readOnly; // hide things we can't touch let toolbar = this.nodeViewerPanel.down("toolbar"); for (let item of toolbar.items.items) { if (item.disabled) { toolbar.remove(item); } } }; let originalUpdateNodePermissions = WikiBrowserPanel.prototype.updateNodePermissions; WikiBrowserPanel.prototype.updateNodePermissions = function() { // prevent a network request from coming out to prevent arousing suspicion and to speed up things let originalPostWithCallback = unsafeWindow.Compass.postWithCallback; unsafeWindow.Compass.postWithCallback = function(url, data, callback) { callback({d: false}); }; originalUpdateNodePermissions.apply(this, arguments); unsafeWindow.Compass.postWithCallback = originalPostWithCallback; }; } 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.startsWith(`${startString}: `)) { 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 // example of panelItem.title being undefined: /Communicate/SchoolDocumentation.aspx if (panelId.startsWith("panel-") && panelItem.title) { 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.pathname === "/Organise/Activities/Activity.aspx" || !window.location.hash.startsWith(`${panelItemHash}&`))) { // Automatically add a reference to the tab when it is clicked window.location.hash = 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); } } // Make permissions grid more visible function handlePermissionsWindow(node) { node.querySelector(".x-mask").remove(); node.querySelector(".x-grid-body").style.pointerEvents = "none"; } 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); } else if (node.classList.contains("x-window")) { let titleElement = node.querySelector(".x-window-header-text"); let title = titleElement ? titleElement.textContent : ""; if (title.startsWith("View Permissions for ")) { handlePermissionsWindow(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") && event.target.parentElement.localName === "body") { // 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) { // workaround window.onhashchange being ignored by window.addEventListener("hashchange", ...) if (window.onhashchange) { window.onhashchange(); } 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 (hashSearch.size === 0 || (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"); } // Prevent clicking "Loading Class Items..." from reloading the current page let loadingClassItems = document.querySelector(".toolbar-classes-loading"); if (loadingClassItems) { loadingClassItems.addEventListener("click", function(event) { event.preventDefault(); }); } // Preload subjects and classes when the page loads let teachingAndLearning = document.querySelector(".toolbar-clickable-teaching-and-learning"); if (teachingAndLearning) { new MutationObserver(function(mutations, observer) { observer.disconnect(); teachingAndLearning.dispatchEvent(new MouseEvent("mouseover")); }).observe(teachingAndLearning, {attributes: true}); } // 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; }