Last active
July 10, 2018 10:10
-
-
Save vyznev/ddb647af6a90964e42d26ba5e0db1815 to your computer and use it in GitHub Desktop.
Incremental Markdown preview for Stack Exchange
This file contains hidden or 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 Incremental Markdown Preview for Stack Exchange | |
// @namespace https://github.com/vyznev/ | |
// @description Speeds up the live Markdown preview on Stack Exchange sites by only updating changed DOM nodes | |
// @author Ilmari Karonen | |
// @version 0.1.0 | |
// @copyright 2017-2018, Ilmari Karonen | |
// @license ISC; https://opensource.org/licenses/ISC | |
// @match *://*.stackexchange.com/* | |
// @match *://*.stackoverflow.com/* | |
// @match *://*.superuser.com/* | |
// @match *://*.serverfault.com/* | |
// @match *://*.stackapps.com/* | |
// @match *://*.mathoverflow.net/* | |
// @match *://*.askubuntu.com/* | |
// @exclude *://chat.*/* | |
// @exclude *://blog.*/* | |
// @homepageURL https://stackapps.com/questions/7765/incremental-markdown-preview-for-stack-exchange | |
// @downloadURL https://gist.github.com/vyznev/ddb647af6a90964e42d26ba5e0db1815/raw/incremental-markdown-preview.user.js | |
// @grant none | |
// @noframes | |
// ==/UserScript== | |
// ISC License: | |
// Copyright 2017-2018 Ilmari Karonen | |
// Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. | |
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | |
var inject = function () { | |
"use strict"; | |
// debug logging verbosity level: 0 = no logging, 1 = minimal logging, 2 = maximum verbosity | |
var logLevel = 1; | |
// helper function for walking a DOM tree | |
function nextNode (node, top) { | |
if ( node.firstChild ) return node.firstChild; | |
while ( node && node !== top && ! node.nextSibling ) node = node.parentNode; | |
return ( (node && node !== top) ? node.nextSibling : null ); | |
} | |
// update the extra properties used to cache the original HTML of a node and its children | |
// TODO: hash these values to save space? | |
function cacheOriginalHTML (root) { | |
for (var node = root; node; node = nextNode(node, root)) { | |
if ( node.nodeType === 3 ) continue; // skip text nodes | |
node.imdpCachedOuterHTML = node.outerHTML; | |
// node.imdpCachedWrapperHTML = node.cloneNode(false).outerHTML; | |
} | |
} | |
// compare old and new nodes based on the old node's cached original HTML | |
function nodesMatch (oldNode, newNode) { | |
if ( oldNode.nodeType !== newNode.nodeType ) return false; | |
if ( oldNode.nodeType === 3 ) return oldNode.nodeValue === newNode.nodeValue; // compare text nodes by value | |
var oldHTML = oldNode.imdpCachedOuterHTML || oldNode.outerHTML; | |
return oldHTML === newNode.outerHTML; | |
} | |
// check if we can (and should) lazily update the old node to match the new node | |
function wrapperMatch (oldNode, newNode) { | |
if ( oldNode.nodeType !== newNode.nodeType ) return false; | |
if ( oldNode.nodeType === 3 ) return false; // don't bother with lazy updates for text nodes | |
var oldWrapper = /* oldNode.imdpCachedWrapperHTML || */ oldNode.cloneNode(false).outerHTML; | |
return oldWrapper === newNode.cloneNode(false).outerHTML; | |
} | |
// lazily update target element's content to match source | |
var skipCount = 0, insertCount = 0, deleteCount = 0, replaceCount = 0, recurseCount = 0; // debug stats | |
function lazyReplaceContent (source, target) { | |
// skip matching initial elements | |
var targetStart = target.firstChild, sourceStart = source.firstChild; | |
while ( targetStart && sourceStart ) { | |
if ( ! nodesMatch(targetStart, sourceStart) ) break; | |
skipCount++; | |
targetStart = targetStart.nextSibling; | |
sourceStart = sourceStart.nextSibling; | |
} | |
// skip matching final elements | |
var targetEnd = null, sourceEnd = null; | |
if ( sourceStart && targetStart) { | |
targetEnd = target.lastChild; | |
sourceEnd = source.lastChild; | |
while ( true ) { | |
if ( ! nodesMatch(targetEnd, sourceEnd) ) { | |
// advance targetEnd and sourceEnd by one step, so they'll point to the first matched pair | |
targetEnd = targetEnd.nextSibling; | |
sourceEnd = sourceEnd.nextSibling; | |
break; | |
} | |
skipCount++; | |
// don't walk back past targetStart and sourceStart | |
if ( targetEnd === targetStart || sourceEnd === sourceStart ) break; | |
targetEnd = targetEnd.previousSibling; | |
sourceEnd = sourceEnd.previousSibling; | |
} | |
} | |
// XXX: There are three common simple cases: pure additions, pure deletions and single-node changes. | |
// More complex cases can appear e.g. if a paragraph is split in two; to handle those optimally, we'd | |
// need to do fuzzy matching to figure out which old child node is the best match for each new child. | |
// Rather than bother with that, we just naively pair off nodes starting from the top. | |
// handle replacements | |
while ( targetStart !== targetEnd && sourceStart !== sourceEnd ) { | |
var targetNext = targetStart.nextSibling, sourceNext = sourceStart.nextSibling; | |
if ( wrapperMatch( targetStart, sourceStart) ) { | |
targetStart.imdpCachedOuterHTML = sourceStart.outerHTML; // update cached original HTML | |
lazyReplaceContent(sourceStart, targetStart); | |
recurseCount++; | |
} else { | |
cacheOriginalHTML(sourceStart); | |
target.replaceChild(sourceStart, targetStart); | |
replaceCount++; | |
} | |
targetStart = targetNext; | |
sourceStart = sourceNext; | |
} | |
// handle deletions | |
while ( targetStart !== targetEnd ) { | |
var next = targetStart.nextSibling; | |
target.removeChild(targetStart); | |
targetStart = next; | |
deleteCount++; | |
} | |
// handle insertions | |
while ( sourceStart !== sourceEnd ) { | |
var next = sourceStart.nextSibling; | |
cacheOriginalHTML(sourceStart); | |
target.insertBefore(sourceStart, targetEnd); | |
sourceStart = next; | |
insertCount++; | |
} | |
} | |
// adds a new setter for a property, preserving existing getters | |
function addSetter (obj, prop, setter) { | |
var proto = obj; | |
while ( proto && ! Object.getOwnPropertyDescriptor(proto, prop) ) { | |
proto = Object.getPrototypeOf(proto); | |
} | |
var desc = ( proto && Object.getOwnPropertyDescriptor(proto, prop) ) || {}; | |
desc.set = setter; | |
Object.defineProperty(obj, prop, desc); | |
} | |
// KLUGE: override the .innerHTML setter for Markdown editor preview panes | |
// to incrementally update the children instead of just overwriting them | |
var parser = new DOMParser(); | |
function makePreviewSmarter () { | |
if (logLevel >= 1) console.log( 'incremental markdown preview initialized for #' + (this.id || '???') ); | |
addSetter( this, 'innerHTML', function (html) { | |
var doc = parser.parseFromString( html, 'text/html' ); | |
skipCount = insertCount = deleteCount = replaceCount = recurseCount = 0; | |
lazyReplaceContent(doc.body, this); | |
if (logLevel >= 2) console.log( 'incremental markdown preview updated #' + (this.id || '???') + | |
': skipped ' + skipCount + | |
', inserted ' + insertCount + | |
', deleted ' + deleteCount + | |
', replaced ' + replaceCount + | |
' and recursed into ' + recurseCount + | |
' nodes.' ); | |
} ); | |
} | |
var guardClass = 'userscript-vyznev-incremental-markdown-preview-applied'; | |
if ( StackExchange.ifUsing ) StackExchange.ifUsing( 'editor', function () { | |
StackExchange.MarkdownEditor.creationCallbacks.add( function (editor, postfix) { | |
$('#wmd-preview' + postfix + ':not(.' + guardClass + ')').each(makePreviewSmarter).addClass(guardClass); | |
} ); | |
} ); | |
$('.wmd-preview:not(.' + guardClass + ')').each(makePreviewSmarter).addClass(guardClass); | |
}; | |
var script = document.createElement( 'script' ); | |
script.textContent = 'window.StackExchange && StackExchange.ready && StackExchange.ready( ' + inject + ' );'; | |
document.body.appendChild( script ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment