userscripts/Compass QoL Enhancer.user.js

353 lines
14 KiB
JavaScript

// ==UserScript==
// @name Compass QoL Enhancer
// @namespace blankie-scripts
// @match http*://*.compass.education/*
// @version 1.11.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 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");
}
}
function handleInstanceButtonClick(event) {
if (event.ctrlKey) {
event.stopImmediatePropagation();
}
}
function handleActivityManager(element) {
let instanceDetails = unsafeWindow.Ext.getCmp(element.id).m_InstanceDetailsWidget;
let instanceNavigatorToolbar = instanceDetails.getInstanceNavigatorToolbar();
let previousInstanceButton = instanceNavigatorToolbar.getComponent("previousInstanceButton");
let nextInstanceButton = instanceNavigatorToolbar.getComponent("nextInstanceButton");
let comboSelectInstance = instanceDetails.getCmbSelectIntance(); // not a typo :)
let realUpdateInstanceHeader = instanceDetails.updateInstanceHeader;
instanceDetails.updateInstanceHeader = function() {
realUpdateInstanceHeader.apply(this, arguments);
updateInstanceButton(instanceDetails, previousInstanceButton, -1);
updateInstanceButton(instanceDetails, nextInstanceButton, 1);
};
for (let button of [previousInstanceButton, nextInstanceButton]) {
button.el.dom.addEventListener("click", handleInstanceButtonClick, {passive: true});
// move all previous handlers back to the front
// new relic's browser agent would've borked this, but it did not??
// i'll take what i can get i guess
// (note to self: unsafeWindow.NREUM = true if this breaks)
for (let handler of unsafeWindow.Ext.EventManager.getEventListenerCache(button, "click")) {
button.el.dom.removeEventListener("click", handler.wrap);
button.el.dom.addEventListener("click", handler.wrap);
}
}
comboSelectInstance.tpl.html = comboSelectInstance.tpl.html.replace("<div ", "<div data-qol-session={id} ");
}
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 preventCompassHandler = false;
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(".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 (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;
// i don't know why but dashboard is dsh
if (window.location.pathname === "/Records/User.aspx" && panelId === "dashboard") {
panelId = "dsh";
}
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
// 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 handleSessionItem(element) {
// Make sessions in the sessions dropdown links
let style = getComputedStyle(element);
let a = document.createElement("a");
a.href = `#session/${element.dataset.qolSession}`;
a.style.display = "block";
a.style.textDecoration = "none";
a.style.padding = style.padding;
a.style.color = style.color;
a.append(...element.childNodes);
a.addEventListener("click", function(event) {
if (event.ctrlKey) {
event.stopImmediatePropagation();
} else {
event.preventDefault();
}
});
element.style.padding = 0;
element.append(a);
}
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.id === "CompassManagersActivityDefaultManager") {
handleActivityManager(node);
}
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 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("x-boundlist-item") && node.dataset.qolSession) {
handleSessionItem(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});
// 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;
}