Skip to content

Instantly share code, notes, and snippets.

@gibson042
Last active September 8, 2023 21:52
Show Gist options
  • Save gibson042/13f4bf8ee4c0905c0abcb0f4edbcf55f to your computer and use it in GitHub Desktop.
Save gibson042/13f4bf8ee4c0905c0abcb0f4edbcf55f to your computer and use it in GitHub Desktop.
Editor Tweaks user script
// ==UserScript==
// @name Editor Tweaks
// @namespace https://github.com/gibson042
// @description <{Command,Ctrl}+[;BIK…]> commands to control Markdown inside textareas, {Command,Ctrl}+Alt+mousedown to toggle monospace.
// @source https://gist.github.com/gibson042/13f4bf8ee4c0905c0abcb0f4edbcf55f
// @updateURL https://gist.github.com/gibson042/13f4bf8ee4c0905c0abcb0f4edbcf55f/raw/editor-tweaks.user.js
// @downloadURL https://gist.github.com/gibson042/13f4bf8ee4c0905c0abcb0f4edbcf55f/raw/editor-tweaks.user.js
// @version 0.5.0
// @date 2023-09-08
// @author Richard Gibson <@gmail.com>
// @include *
// ==/UserScript==
//
// **COPYRIGHT NOTICE**
//
// To the extent possible under law, the author(s) have dedicated all copyright
// and related and neighboring rights to this software to the public domain
// worldwide. This software is distributed without any warranty.
// For the CC0 Public Domain Dedication, see
// <https://creativecommons.org/publicdomain/zero/1.0/>.
//
// **END COPYRIGHT NOTICE**
//
//
// Changelog:
// 0.5.0 (2023-09-08)
// * New: CC0 Public Domain Dedication.
// 0.4.0 (2022-10-03)
// * Improved: Support text input boxes and arbitrary editable elements.
// 0.3.0 (2022-06-07)
// * Improved: Recognize `*` as a removal-only alternative for `_`.
// 0.2.5 (2022-03-24)
// * Fixed: Compatibility with non-HTML documents.
// 0.2.3 (2022-03-21)
// * Improved: Better OS and user script manager compatibility.
// 0.2.2 (2022-02-02)
// * New: Holding the mouse button down while Control and Alt are pressed
// toggles monospace up the DOM, one parent per short tick.
// 0.1.2 (2020-08-07)
// * Improved: A <Ctrl> command with empty selection skips over an
// immediately following instance of its formatting sequence.
// * Improved: Selection for empty links starts on the text placeholder.
// 0.1.1 (2020-08-05)
// * New: Added support for more formatting sequences.
// * Improved: Made changes participate in undo/redo.
// 0.1.0 (2020-05-26)
// * original release
(function () {
"use strict";
const ID="gibson042-editor-tweaks";
(document.head || document.body)?.insertAdjacentHTML?.("beforeend", `
<style type="text/css" class="${ID}">
.${ID}_mono {
font-family: monospace !important;
}
.${ID}_highlight {
background-color: palevioletred !important;
}
.${ID}_flash {
transition: background-color 1s !important;
}
</style>
`);
const actions = new Map([
[";", simpleWrapToggler("`")],
["b", simpleWrapToggler("**")],
["i", simpleWrapToggler("_", "*")],
["k", linkToggler],
]);
function simpleWrapToggler( wrap, ...alternatives ) {
const length = wrap.length;
return function simpleWrapToggler( prefix, selection, suffix ) {
// Remove an existing wrapper just inside the selection.
if ( selection.length >= (2 * length) && selection.startsWith(wrap) && selection.endsWith(wrap) ) {
return { preAdjust: [0, 0], replacement: selection.slice(length, -length), postAdjust: [0, 0] };
}
// Remove an existing wrapper just outside the selection.
if ( prefix.endsWith(wrap) && suffix.startsWith(wrap) ) {
return { preAdjust: [-length, length], replacement: selection, postAdjust: [0, 0] };
}
// Remove an existing alternative wrapper just inside or outside the selection.
for ( const wrap of alternatives ) {
const length = wrap.length;
if ( selection.length >= (2 * length) && selection.startsWith(wrap) && selection.endsWith(wrap) ) {
return { preAdjust: [0, 0], replacement: selection.slice(length, -length), postAdjust: [0, 0] };
}
if ( prefix.endsWith(wrap) && suffix.startsWith(wrap) ) {
return { preAdjust: [-length, length], replacement: selection, postAdjust: [0, 0] };
}
}
// Special case: skip over a wrapper half just after an empty selection.
if ( !selection && suffix.startsWith(wrap) ) {
return { preAdjust: [0, 0], replacement: "", postAdjust: [wrap.length, wrap.length] };
}
// Wrap the selection.
return { preAdjust: [0, 0], replacement: wrap + selection + wrap, postAdjust: [length, -length] };
};
}
function linkToggler( prefix, selection, suffix ) {
// Detect when the selection is already a link, or a link text or destination.
// https://spec.commonmark.org/0.29/#links
let matchInside = reLinkPart.exec(selection) || {groups: {}};
let matchOutside = prefix && suffix &&
reLinkPart.exec(prefix.slice(-1) + selection + suffix[0]) || {groups: {}};
let buffer;
// If the selection is a link text or link target, remove the other part.
if ( matchInside.groups.text !== undefined && (buffer = reAnchoredLinkDestination.exec(suffix)) ) {
return { preAdjust: [0, buffer[0].length], replacement: selection.slice(1, -1), postAdjust: [0, 0] };
}
if ( matchOutside.groups.text !== undefined && (buffer = reAnchoredLinkDestination.exec(suffix.slice(1))) ) {
return { preAdjust: [-1, buffer[0].length + 1], replacement: selection, postAdjust: [0, 0] };
}
if ( matchInside.groups.target !== undefined && (buffer = reAnchoredLinkText.exec(prefix)) ) {
return { preAdjust: [-buffer[0].length, 0], replacement: selection.slice(1, -1), postAdjust: [0, 0] };
}
if ( matchOutside.groups.target !== undefined && (buffer = reAnchoredLinkText.exec(prefix.slice(0, -1))) ) {
return { preAdjust: [-buffer[0].length - 1, 1], replacement: selection, postAdjust: [0, 0] };
}
// Special case: skip over formatting just after an empty selection.
if ( !selection && suffix.startsWith(")") ) {
return { preAdjust: [0, 0], replacement: "", postAdjust: [1, 1] };
}
if ( !selection && suffix.startsWith("]") && (buffer = reAnchoredLinkDestination.exec(suffix.slice(1))) ) {
let n = 1 + buffer[0].length;
return { preAdjust: [0, 0], replacement: "", postAdjust: [n, n] };
}
// If the selection is empty or a URI, make it a link target and highlight a text placeholder.
// Otherwise, make it a link text and highlight a destination placeholder.
// https://tools.ietf.org/html/rfc3986#section-3
let isUri = /^[a-z][a-z0-9+.-]*:\S*$/i.test(selection);
if ( !selection || isUri ) {
let placeholder = "text";
return { preAdjust: [0, 0], replacement: `[${placeholder}](${selection})`, postAdjust: [1, -selection.length - 3] };
} else {
let placeholder = "target";
return { preAdjust: [0, 0], replacement: `[${selection}](${placeholder})`, postAdjust: [selection.length + 3, -1] };
}
}
// CommonMark recommends supporting at least three levels of nesting for unescaped bracket/parentheses pairs,
// but we only support one nested level (e.g., `[start[middle]end](...)` and `[...](start(middle)end)`).
// https://spec.commonmark.org/0.29/#links
const pLinkText = String.raw`
<<<safeChars>>>
(?:
(?:<<bracketed>> \[ <<<safeChars>>> \] )
<<<safeChars>>>
)*
`.replace(/<<<safeChars>>>/g, String.raw`(?:(?=.)[^\\\x5B-\x5D]|\\.)*`).replace(/\s+|<<.*?>>/g, "");
const pLinkDestination = String.raw`(?: < (?:(?=.)[^\\>]|\\.)* > |
<<<safeChars>>>
(?:
(?:<<parenthesized>> \( <<<safeChars>>> \) )
<<<safeChars>>>
)*
)`.replace(/<<<safeChars>>>/g, String.raw`(?:(?=.)[^\\()]|\\.)*`).replace(/\s+|<<.*?>>/g, "");
const reLinkPart = new RegExp(String.raw`^\[(?<text>${pLinkText})\]$|^\((?<target>${pLinkDestination})\)$`);
const reAnchoredLinkDestination = new RegExp(String.raw`^\(${pLinkDestination}\)`);
const reAnchoredLinkText = new RegExp(String.raw`(?<!(?:^|\n|[^\\])\\(?:\\\\)*)\[${pLinkText}\]$`);
// Listen for recognized <Command|Ctrl> commands in an editable element to apply/unapply Markdown formatting to the selection,
// spying at document level on the capture phase and processing in the bubbling phase if nothing relevant occurred in between them.
const getSelection = el=>{
if ( /^input$|^textarea$/i.test(el.nodeName) ) {
// <input> and <textarea> have a string `value` and expose selections directly.
const {value, selectionStart, selectionEnd, selectionDirection} = el;
return {
node: el,
nodeValueProperty: "value",
nodeValue: value,
selectionStart,
selectionEnd,
replaceSelection: (a, b)=>{ el.setSelectionRange(a, b, selectionDirection); },
};
}
// We can also process single-text-node selections in editable elements.
if ( el.isContentEditable !== true ) return;
const selection = el.ownerDocument.getSelection() || Object.create(null);
const {type, rangeCount, anchorNode, anchorOffset, focusNode, focusOffset} = selection;
if ( type !== "Range" || rangeCount !== 1 || focusNode !== anchorNode || focusNode.nodeType !== 3 || !el.contains(focusNode) ) return;
return {
node: focusNode,
nodeValueProperty: "nodeValue",
nodeValue: focusNode.nodeValue,
selectionStart: Math.min(anchorOffset, focusOffset),
selectionEnd: Math.max(anchorOffset, focusOffset),
replaceSelection: (a, b)=>{ if(anchorOffset > focusOffset) [a, b] = [b, a]; selection.setBaseAndExtent(focusNode, a, anchorNode, b); },
};
};
document.addEventListener("keydown", function( evtCaptured ) {
let { target, key, altKey, ctrlKey, metaKey, shiftKey } = evtCaptured;
let handler = (ctrlKey ^ metaKey) && !altKey && !shiftKey && actions.get(key);
const initialSelection = handler && getSelection(target);
if ( !initialSelection ) return;
// Wait for the event to bubble back up.
const onBubble = evtBubbled=>{
if ( evtBubbled.target !== target || evtBubbled.key !== key ) return;
// Extract current value and selection data.
let {node, nodeValueProperty, nodeValue, selectionStart, selectionEnd, replaceSelection} = getSelection(target) || Object.create(null);
let prefix = nodeValue.slice(0, selectionStart);
let selection = nodeValue.slice(selectionStart, selectionEnd);
let suffix = nodeValue.slice(selectionEnd);
// Abort if the content has changed.
if ( node !== initialSelection.node ||
nodeValue !== initialSelection.nodeValue ||
selectionStart !== initialSelection.selectionStart ||
selectionEnd !== initialSelection.selectionEnd ) return;
// Verify that there are appropriate changes.
let result = handler(prefix, selection, suffix);
if ( !result ) return;
let {preAdjust, replacement, postAdjust} = result;
// Adjust the selection as instructed, then replace it.
prefix = nodeValue.slice(0, selectionStart + preAdjust[0]);
suffix = nodeValue.slice(selectionEnd + preAdjust[1]);
replaceSelection(prefix.length, nodeValue.length - suffix.length);
try {
// Prefer document.execCommand, which participates in undo/redo.
if ( !document.execCommand("insertText", false, replacement) ) throw new Error();
} catch ( err ) {
// Fall back on wholesale replacement.
node[nodeValueProperty] = prefix + replacement + suffix;
}
// Update the post-replacement selection as instructed.
replaceSelection(prefix.length + postAdjust[0], prefix.length + replacement.length + postAdjust[1]);
// Prevent default behavior, which can steal focus (<Ctrl+U> view source, <Ctrl+K> search, etc.).
evtBubbled.preventDefault();
};
document.addEventListener("keydown", onBubble, {once: true});
}, {capture: true, passive: true});
// Listen for mousedown with <{Command,Ctrl}+Alt> to toggle monospace font rendering up the DOM.
const toggling = new WeakMap();
document.addEventListener("mousedown", evt=>{
let { target, altKey, ctrlKey, metaKey, shiftKey } = evt;
if (!((ctrlKey ^ metaKey) && altKey && !shiftKey)) return;
// Capture ancestors so resulting clicks can be intercepted at the document level.
let parents = new Set();
for (let el = target; el?.nodeType === 1; el = el.parentNode) {
parents.add(el);
toggling.set(el, cleanup);
}
// Start toggling.
let resolvedFonts = (getComputedStyle(target).getPropertyValue("font-family") || "").split(reFontFamilyTokens);
let isMonospacing = resolvedFonts.length < 2 || !reMonospace.test(resolvedFonts[1]);
let toggle = el=>{
parents.add(el);
toggling.set(el, cleanup);
el.classList.toggle(ID + "_mono", isMonospacing);
el.classList.remove(ID + "_flash");
el.classList.add(ID + "_highlight");
requestAnimationFrame(()=>{el.classList.add(ID + "_flash"); el.classList.remove(ID + "_highlight")});
el.addEventListener("transitionend", evt=>el.classList.remove(ID + "_flash"), {once: true, passive: true});
};
toggle(target);
let ancestor = target;
let timer = setInterval(()=>{
ancestor = ancestor.parentNode;
if(ancestor?.nodeType !== 1) return cleanup();
toggle(ancestor);
}, 1500);
// Cleanup on mouseup.
document.addEventListener("mouseup", cleanup, {once: true});
function cleanup(evt) {
if (evt?.type === "click") evt.preventDefault();
// Defer actual cleanup to prevent mouseup from pulling the rug out from under click.
setTimeout(()=>{
for (let el of parents) toggling.delete(el);
parents = new Set();
document.removeEventListener("mouseup", cleanup);
// clearInterval is last in case timer is not yet initialized
// (in which case this will throw a harmless error).
clearInterval(timer);
});
}
}, {passive: true});
document.addEventListener("click", evt=>{
for (let el = evt.target; el?.nodeType === 1; el = el.parentNode) {
let cleanup = toggling.get(el);
if (!cleanup) continue;
cleanup(evt);
break;
}
});
const reMonospace = /^(?:monospace|"monospace"|'monospace')$/i;
// Each comma-separated item in font-family is either
// a quoted string or an identifier.
// https://drafts.csswg.org/css-fonts/#font-family-prop
// https://www.w3.org/TR/css-syntax-3/#string-token-diagram
// https://www.w3.org/TR/css-syntax-3/#ident-token-diagram
const reFontFamilyTokens = new RegExp([
String.raw`(?:[\x20\t\n]|[/][*][^]*?[*][/])*(`,
[
`,`,
String.raw`'(?:\\.|\\\n|[^\\\n'])*'`,
String.raw`"(?:\\.|\\\n|[^\\\n"])*"`,
String.raw`(?:--|-?(?:\\[^0-9a-f\n]|\\[0-9a-f]{1,6}[\x20\t\n]?|[a-z_\x80-]))` +
String.raw`(?:\\[^0-9a-f\n]|\\[0-9a-f]{1,6}[\x20\t\n]?|[a-z0-9_\-\x80-])*`,
].join(`|`),
String.raw`)(?:[\x20\t\n]|[/][*][^]*?[*][/])*`
].join(""), "gi");
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment