Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Incremental Markdown preview for Stack Exchange
// ==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
You can’t perform that action at this time.