Skip to content

Instantly share code, notes, and snippets.

@meooow25
Last active February 15, 2022 21:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save meooow25/8c91d4b111057f225c1d66e46cf00de2 to your computer and use it in GitHub Desktop.
Save meooow25/8c91d4b111057f225c1d66e46cf00de2 to your computer and use it in GitHub Desktop.
CF Linemaster
// ==UserScript==
// @name CF Linemaster
// @namespace https://github.com/meooow25
// @match *://*.codeforces.com/*
// @grant GM.setClipboard
// @version 0.9
// @author meooow
// @description Adds supports for line highlighting and copying on Codeforces
// @downloadURL https://gist.github.com/meooow25/8c91d4b111057f225c1d66e46cf00de2/raw/cf-linemaster.user.js
// ==/UserScript==
/**
* This script enables line highlighting for Codeforces submissions.
*
* On a submission page or on a submission popup:
* Click on a line number to highlight the line, Shift+click on another line
* number to select all lines in between.
* Press Ctrl+Shift+C to copy all highlighted lines.
*
* On a submission page:
* You can set #L<start> or #L<start>-L<end> as the URL fragment to specify
* the lines to highlight.
*/
/**
* Changelog:
* v0.9 Added support for EDU submission, status and standings pages.
* Disabled inspect element opening on copy.
* v0.8 Updated highlight color to work with dark themes.
* v0.7 Added support for standings pages.
* v0.6 Added support for submission popups.
* Added support for problemset and acmsguru URLs.
* Added an option to remove extra indent from copied code.
* Removed unnecessary GM_setClipboard permission.
*/
(async function() {
'use strict';
// Set to false to leave extra indent in code when copying with Ctrl+Shift+C.
const REMOVE_EXTRA_INDENT_ON_COPY = true;
const SUBMISSION_PAGE_REGEXES = [
String.raw`^https?://codeforces.com/(?:contest|gym)/\d+/submission/\d+`,
String.raw`^https?://codeforces.com/(?:problemset|problemsets/acmsguru)/submission/\d+/\d+`,
String.raw`^https?://codeforces.com/edu/course/\d+/lesson/\d+/\d+/practice/contest/\d+/submission/\d+`,
].map(s => new RegExp(s));
const STATUS_AND_STANDINGS_PAGE_REGEXES = [
String.raw`^https?://codeforces.com/(?:contest|gym)/\d+/(?:status|my|standings)`,
String.raw`^https?://codeforces.com/(?:problemset|problemsets/acmsguru)/status`,
String.raw`^https?://codeforces.com/submissions/.+`, // User status pages
String.raw`^https?://codeforces.com/edu/course/\d+/lesson/\d+/(?:standings|\d+/practice/(?:status|standings))`,
].map(s => new RegExp(s));
const ON_SUBMISSION_PAGE = SUBMISSION_PAGE_REGEXES.some(re => re.test(location.href));
const ON_STATUS_OR_STANDINGS_PAGE = STATUS_AND_STANDINGS_PAGE_REGEXES.some(re => re.test(location.href));
if (!ON_SUBMISSION_PAGE && !ON_STATUS_OR_STANDINGS_PAGE) {
return;
}
const HIGHLIGHTED_LI_CLS = 'cf-linemaster-highlighted-li';
const LINE_NUMS_CONTAINER_ID = 'cf-linemaster-line-nums';
const LINE_NUM_LI_CLS = 'cf-linemaster-line-num';
const CONTAINER_DIV_ID = 'cf-linemaster-line-nums-and-source-container';
// On submission page
const SOURCE_PRE_ID = 'program-source-text';
const SCROLL_TO_HIGHLIGHTED_TOP_OFFSET = 40;
// On status or standings page
const FACEBOX_DIV_ID = 'facebox';
const SOURCE_CODE_CLS = 'source-popup-source';
const SOURCE_PRE_ADDED_ID = 'cf-linemaster-source-pre';
const CSS = `
#${CONTAINER_DIV_ID} {
display: flex;
line-height: 1.25;
${ON_SUBMISSION_PAGE ? 'margin-top: -0.3em;' : ''}
}
#${SOURCE_PRE_ADDED_ID} {
border: 1px solid rgb(185, 185, 185);
border-radius: 3px;
}
#${LINE_NUMS_CONTAINER_ID} {
text-align: right;
padding: 0.5em;
user-select: none;
opacity: 0.5;
}
.${LINE_NUM_LI_CLS} {
cursor: pointer;
}
#${SOURCE_PRE_ID},
.${SOURCE_CODE_CLS} {
padding: 0.5em 0;
border: none;
border-left: 1px solid rgb(185, 185, 185);
flex-grow: 1;
}
#${SOURCE_PRE_ID} li,
.${SOURCE_CODE_CLS} li {
padding-left: 0.5em;
}
.${HIGHLIGHTED_LI_CLS} {
background-color: rgba(215, 180, 35, 0.25);
}
`;
function createLineNumsContainer() {
let elem;
if (ON_SUBMISSION_PAGE) {
elem = document.createElement('pre');
} else { // ON_STATUS_OR_STANDINGS_PAGE
elem = document.createElement('code');
}
elem.id = LINE_NUMS_CONTAINER_ID;
return elem;
}
function getSourceCodeContainer() {
if (ON_SUBMISSION_PAGE) {
return document.getElementById(SOURCE_PRE_ID);
} else { // ON_STATUS_OR_STANDINGS_PAGE
const facebox = document.getElementById(FACEBOX_DIV_ID);
return facebox && facebox.querySelector(`.${SOURCE_CODE_CLS}`);
}
}
function tweakSourceContainer() {
if (ON_SUBMISSION_PAGE) {
getSourceCodeContainer().style.padding = null;
} else { // ON_STATUS_OR_STANDINGS_PAGE
getSourceCodeContainer().closest('pre').id = SOURCE_PRE_ADDED_ID;
}
}
let currentRange = { start: null, end: null };
let disableScrollOnce = false;
function setCurrentRange(start, end) {
if (start > end) {
[start, end] = [end, start];
}
if (start === currentRange.start && end === currentRange.end) {
return false;
}
currentRange = { start: start, end: end };
return true;
}
function highlightCurrentRange() {
Array.from(document.getElementsByClassName(HIGHLIGHTED_LI_CLS))
.forEach(li => li.classList.remove(HIGHLIGHTED_LI_CLS));
Array.from(getSourceCodeContainer().querySelectorAll('li'))
.slice(currentRange.start - 1, currentRange.end)
.forEach(li => li.classList.add(HIGHLIGHTED_LI_CLS));
}
function updateCurrentRange(start, end) {
const changed = setCurrentRange(start, end);
if (!changed) {
return;
}
if (ON_SUBMISSION_PAGE) {
// The hash change listener calls highlightCurrentRange.
disableScrollOnce = true;
if (currentRange.start === currentRange.end) {
location.hash = `L${currentRange.start}`;
} else {
location.hash = `L${currentRange.start}-L${currentRange.end}`;
}
} else { // ON_STATUS_OR_STANDINGS_PAGE
highlightCurrentRange();
}
}
function hashUpdated() {
const matches = /L(\d+)(?:-L(\d+))?/.exec(location.hash);
if (!matches) {
return;
}
const start = parseInt(matches[1]);
const end = matches[2] ? parseInt(matches[2]) : start;
setCurrentRange(start, end);
highlightCurrentRange();
if (disableScrollOnce) {
disableScrollOnce = false;
return;
}
const first = document.querySelector(`.${HIGHLIGHTED_LI_CLS}`);
if (first) {
window.scrollTo(
0,
first.getBoundingClientRect().top
+ window.scrollY - SCROLL_TO_HIGHLIGHTED_TOP_OFFSET);
}
}
function updatePage() {
tweakSourceContainer();
const sourceCodeContainer = getSourceCodeContainer();
const numLines = sourceCodeContainer.querySelectorAll('li').length;
const lineNumsContainer = createLineNumsContainer();
for (let i = 1; i <= numLines; i++) {
const div = document.createElement('div');
div.classList.add(LINE_NUM_LI_CLS);
div.textContent = i;
lineNumsContainer.appendChild(div);
}
const containerDiv = document.createElement('div');
containerDiv.id = CONTAINER_DIV_ID;
sourceCodeContainer.replaceWith(containerDiv);
containerDiv.appendChild(lineNumsContainer);
containerDiv.appendChild(sourceCodeContainer);
lineNumsContainer.addEventListener('click', e => {
if (!e.target.classList.contains(LINE_NUM_LI_CLS)) {
return;
}
const lineNum = parseInt(e.target.textContent);
if (e.shiftKey && currentRange.start) {
updateCurrentRange(currentRange.start, lineNum);
} else {
updateCurrentRange(lineNum, lineNum);
}
});
}
function getHighlightedLines() {
const lines =
Array.from(document.getElementsByClassName(HIGHLIGHTED_LI_CLS))
.map(li => li.textContent);
if (!REMOVE_EXTRA_INDENT_ON_COPY) {
return lines.join('\n');
}
// This logic assumes indent consists of only tabs or only spaces, if that
// is not the case the text is already ugly and the copy will also be ugly.
let minSpaceCount = Infinity;
for (const line of lines) {
const result = /\S+/.exec(line);
if (result) {
minSpaceCount = Math.min(minSpaceCount, result.index);
}
}
return lines.map(line => line.slice(minSpaceCount)).join('\n').trim();
}
function showMessage(text) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.text = `Codeforces.showMessage('${text}');`;
document.head.appendChild(script);
script.remove();
}
function setupCopyShortcut() {
document.addEventListener('keydown', e => {
if (e.key.toLowerCase() === 'c' && e.shiftKey && e.ctrlKey) {
e.preventDefault(); // Disable inspect element opening
}
});
document.addEventListener('keyup', async e => {
if (e.key.toLowerCase() === 'c' && e.shiftKey && e.ctrlKey) {
const highlighted = getHighlightedLines();
if (highlighted) {
await GM.setClipboard(highlighted);
showMessage('The highlighted text has been copied into the clipboard');
}
}
});
}
function addStyle() {
const style = document.createElement('style');
style.type = 'text/css';
style.textContent = CSS;
document.head.appendChild(style);
}
function isPrettyPrintDone() {
const sourceCodeContainer = getSourceCodeContainer();
return sourceCodeContainer && sourceCodeContainer.querySelector('ol');
}
function prettyPrintDone() {
updatePage();
if (ON_SUBMISSION_PAGE) {
hashUpdated();
window.addEventListener('hashchange', hashUpdated);
}
}
if (ON_SUBMISSION_PAGE && isPrettyPrintDone()) {
prettyPrintDone();
} else {
const observer = new MutationObserver(mutationsList => {
for (const mutation of mutationsList) {
for (const node of mutation.addedNodes) {
let sourceCodeContainer;
const isPrettyPrintContainer =
node.nodeName === 'OL' &&
node.classList.contains('linenums') &&
(sourceCodeContainer = getSourceCodeContainer()) &&
sourceCodeContainer.contains(node);
if (isPrettyPrintContainer) {
prettyPrintDone();
if (ON_SUBMISSION_PAGE) {
observer.disconnect();
}
return;
}
}
}
});
// For status or standings page, the facebox gets added later so can't just
// put the observer on it on load.
observer.observe(document.body, { childList: true, subtree: true });
}
addStyle();
setupCopyShortcut();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment