// ==UserScript==
// @name 프로그래머스 키보드 단축키 모음
// @namespace http://shrk.dev/
// @version 0.1.1
// @description 프로그래머스 문제 풀 때 유용한 단축키 모음. 코드 작성 도중 마우스를 사용하지 않고 코드 실행(Ctrl+Enter), 제출 및 채점(Ctrl+Shift+Enter), 문제 설명 스크롤(Ctrl+J/K) 및 패널 크기 조정(Ctrl+;/')을 할 수 있습니다. 또한 문제 채점 결과 창을 스페이스 또는 엔터로 닫을 수 있습니다.
// @author qb20nh
// @match https://school.programmers.co.kr/learn/courses/*/lessons/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=programmers.co.kr
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const SCROLL_LINES = 5;
const RESIZE_PERCENT = 5;
const DEBUG = false;
function forElement(selector, rootNode = document) {
return new Promise((resolve, reject) => {
const element = rootNode.querySelector(selector);
if (element) {
resolve(element);
return;
}
const observer = new MutationObserver(mutations => {
const element = rootNode.querySelector(selector);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(rootNode, {
childList: true,
subtree: true
});
});
}
function forProperty(obj, propName, timeout = 3000, interval = 100) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
// Function to check the property
function checkProperty() {
// If the property exists, resolve the promise with its value
if (propName in obj) {
resolve(obj[propName]);
} else {
// If the timeout has not elapsed, check again after the interval
if (Date.now() - startTime < timeout) {
setTimeout(checkProperty, interval);
} else {
// If the timeout has elapsed, reject the promise
reject(new Error(`Property ${propName} was not found within ${timeout}ms`));
}
}
}
// Start the polling
checkProperty();
});
}
/**
* @see https://stackoverflow.com/a/18430767/4592648
*/
function calculateLineHeight (element) {
let lineHeight = parseInt(getComputedStyle(element).getPropertyValue('line-height'), 10);
if (isNaN(lineHeight)) {
const clone = element.cloneNode();
clone.innerHTML = '<br>';
element.appendChild(clone);
const singleLineHeight = clone.offsetHeight;
clone.innerHTML = '<br><br>';
const doubleLineHeight = clone.offsetHeight;
element.removeChild(clone);
lineHeight = doubleLineHeight - singleLineHeight;
}
return lineHeight;
}
function scrollByLines(elem, lines) {
const lineHeight = calculateLineHeight(elem);
elem.scrollTo({top: elem.scrollTop + lineHeight*lines, behavior: 'smooth'});
}
function runAfterLoad(fn) {
if (document.readyState !== 'loading') {
fn();
} else {
document.addEventListener('readystatechange', fn, {once: true});
}
}
runAfterLoad(async () => {
const cm = await forProperty(await forElement('.CodeMirror'), 'CodeMirror');
if (DEBUG) {
window.cm = cm;
}
const codeMirrorTextarea = document.querySelector('.CodeMirror textarea');
const runBtn = document.getElementById('run-code');
const submitBtn = document.getElementById('submit-code');
const guide = document.getElementById('tour2');
const code = document.querySelector('.run-section');
const guideWidthStyleRE = /calc\((?<guideWidth>\d+(?:\.\d+)?)%\s*-\s*(?<gutterWidth>\d+(?:\.\d+)?)px\)/;
const codeWidthStyleRE = /calc\((?<codeWidth>\d+(?:\.\d+)?)%\s*-\s*(?<gutterWidth>\d+(?:\.\d+)?)px\)/;
function resizeElements(guide, code, dir) {
const {guideWidth, gutterWidth: gutterWidth1} = guide.style.width.match(guideWidthStyleRE).groups;
const {codeWidth, gutterWidth: gutterWidth2} = code.style.width.match(guideWidthStyleRE).groups;
if (gutterWidth1 !== gutterWidth2) {
console.warn(`gutter width offset is not the same!`);
}
const guidePercent = parseInt(guideWidth);
const codePercent = parseInt(codeWidth);
if (Math.abs(guidePercent + codePercent - 100) > 0.1) {
console.warn(`width percent sum is not 100%`);
}
const adjustAmountPercent = RESIZE_PERCENT * dir;
const clamp = (num, min = 0, max = 100) => Math.max(min, Math.min(num, max));
const newGuidePercent = clamp(guidePercent + adjustAmountPercent);
const newCodePercent = clamp(codePercent - adjustAmountPercent);
const newStyle = (width) => `calc(${width}% - ${gutterWidth1}px)`;
guide.style.width = newStyle(newGuidePercent);
code.style.width = newStyle(newCodePercent);
}
const ACTION = Object.freeze({
NONE: Symbol('ACTION.NONE'),
RUN: Symbol('ACTION.RUN'),
SUBMIT: Symbol('ACTION.SUBMIT'),
SCROLL: Symbol('ACTION.SCROLL'),
RESIZE: Symbol('ACTION.RESIZE'),
DISMISS_MODAL: Symbol('ACTION.DISMISS_MODAL'),
});
let lastEdit = cm.doc.history.generation;
let lastAnchor = cm.getCursor('anchor');
let lastHead = cm.getCursor('head');
// Your code here...
document.addEventListener('keydown', ({ctrlKey, shiftKey, key}) => {
let action;
try {
if (ctrlKey && key === 'Enter') {
action ??= shiftKey ? ACTION.SUBMIT : ACTION.RUN;
}
if (ctrlKey && 'jk'.includes(key)) {
action ??= ACTION.SCROLL;
}
if (ctrlKey && ';\''.includes(key)) {
action ??= ACTION.RESIZE;
}
if (['Enter', ' '].includes(key)) {
action ??= ACTION.DISMISS_MODAL;
}
action ??= ACTION.NONE;
if (action !== ACTION.NONE) {
if (action === ACTION.SCROLL) {
let scrollDir = 0;
if (key === 'j') {
scrollDir = -1;
}
if (key === 'k') {
scrollDir = 1;
}
if (scrollDir !== 0) {
scrollByLines(guide, SCROLL_LINES * scrollDir);
}
return;
}
if (action === ACTION.RESIZE) {
let resizeDir = 0;
if (key === ';') {
resizeDir = -1;
}
if (key === '\'') {
resizeDir = 1;
}
if (resizeDir != 0) {
resizeElements(guide, code, resizeDir);
}
return;
}
if (action === ACTION.DISMISS_MODAL) {
const modalBtn = document.querySelector('#modal-dialog .btn.btn-primary');
modalBtn?.click?.();
setTimeout(() => cm.focus());
}
const focusElement = document.querySelector(':focus');
if (focusElement === codeMirrorTextarea) {
if (action === ACTION.RUN) {
runBtn.click();
}
if (action === ACTION.SUBMIT) {
submitBtn.click();
}
}
}
} finally {
const thisEdit = cm.doc.history.generation;
const thisAnchor = cm.getCursor('anchor');
const thisHead = cm.getCursor('head');
const compare = (a, b) => JSON.stringify(a) === JSON.stringify(b);
let didSelectionChange = false;
if (ctrlKey && action !== ACTION.NONE) {
if (thisEdit !== lastEdit) {
cm.execCommand('undo');
}
if (!compare(thisAnchor, lastAnchor) || !compare(thisHead, lastHead)) {
cm.setSelection(lastAnchor, lastHead);
didSelectionChange = true;
}
}
lastEdit = thisEdit;
if (!didSelectionChange) {
lastAnchor = thisAnchor;
lastHead = thisHead;
}
}
}, {
passive: true
});
});
})();