Last active
September 8, 2023 21:52
-
-
Save gibson042/13f4bf8ee4c0905c0abcb0f4edbcf55f to your computer and use it in GitHub Desktop.
Editor Tweaks user script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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