520 lines
20 KiB
JavaScript
520 lines
20 KiB
JavaScript
// ==UserScript==
|
|
// @name Compass QoL Enhancer
|
|
// @namespace blankie-scripts
|
|
// @match http*://*.compass.education/*
|
|
// @version 1.17.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;
|
|
};
|
|
}
|
|
|
|
function handleNewsItemClick() {
|
|
if (!this.moreButton.hidden) {
|
|
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, {single: true});
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 handleLearningTaskShow() {
|
|
let search = getHashSearch();
|
|
search.set("qol_learning_task", this.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);
|
|
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) {
|
|
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 (.+?) style="(.+?)">(.+?)<\/div>/,
|
|
"<a href='#session/{id}' onclick='event.ctrlKey && event.stopImmediatePropagation()' $1 style='display: block; text-decoration: none; $2'>$3</a>"
|
|
);
|
|
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 original = WikiBrowserPanel.prototype.renderLayout;
|
|
WikiBrowserPanel.prototype.renderLayout = function() {
|
|
original.apply(this, arguments);
|
|
this.treePanel.store.sort({sorterFn: wikiNodeSort});
|
|
}
|
|
}
|
|
|
|
|
|
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-<id>: /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();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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")) {
|
|
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("x-grid-view") && unsafeWindow.Ext.getCmp(node.id).up("#submissionsPanel")) {
|
|
handleLearningTaskSubmissionTable(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");
|
|
}
|
|
|
|
// 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;
|
|
} |