The problem
I often hear this feature request:
When I read a long page in a wiki, and I want to correct something in it, I have to scroll to the bottom, click "edit", find the right spot in the editor, correct it, save, then find the place again and continue reading.
Please, make it so that I can click anywhere on the page, and the editor will come up scrolled to that spot. Thank you.
Sounds pretty basic, doesn't it? Unfortunately, the canonical response to such a request is "this is not possible". Why not? Thousands of wiki users are doing it each time they edit a page, if they can do it, why can't the computer do it? There is a number of difficult problems…
First of all, there isn't always any direct correspondence between a spot clicked in the rendered wiki page, and a fragment of the page's source text. Even when there is a direct correspondence (or something acceptably near to it), it's not easy to determine it: you have to keep track of your position when you are parsing the wiki text, and then somehow record that position in the generated HTML. Alas, this has been done: MoinMoin wiki does it, at least with the accuracy to lines, when it displays diffs of changes, with links to particular changed parts. So this is difficult, but doable. But that's not enough.
Second, you need to determine where the user clicked on the page, find the information about the position in wiki text that you recorded in the HTML somehow, and pass it to the editor. This is difficult for arbitrary spots and with character-grained precision, but you don't actually need it that accurate: you can, for example, have links on the page's margin, with the recorded line number hardwired in their URLs. Or you can register a JavaScript callback, so that the right URL with the line number in it is visited when you double-click an HTML element that has its line number recorded. You can even do it with unobtrusive JavaScript. Not perfect, but acceptable for a start.
Finally, after all the trouble, you just need one final step, one cosmetic change to your wiki editor form, a triviality hardly worth mentioning. Formality, really. You have to move the cursor to the specified line, and scroll the textarea, so that the cursor is visible, once you're at it. Sounds simple? It is not.
Scroll the Editor
While you can easily scroll the web page, with fancy HTML and images and embedded movies and whatnot inside, to any element inside, you can't easily scroll the textarea to a specific line. Oh, of course, you can scroll it. By any specified amount of pixels. Oh, and you can move the cursor. By any specified amount of characters – left or right, but the only way to move the cursor up or down is to move it left or right until it wraps.
That fact that each and every web browser out there requires you to use different code to actually do the scrolling and cursor movement, and that even when you use the right commands, they work pretty much randomly, doesn't help. But hey, it's just the last final step, let's take it and be over with it. It must be possible…
Move the Cursor
We will start with the cursor position, because it seems to be slightly simpler. All you have to do is to iterate over your lines of raw wiki text, adding up their lengths, until you hit the line you want. Then move the cursor to the calculated position with this simple cross-browser code:
var cursorPosition = %d; // put your calculated position here
var textBox=document.getElementById("editortext");
if (textBox.setSelectionRange) {
textBox.focus()
textBox.setSelectionRange(cursorPosition, cursorPosition);
} else if (textBox.createTextRange) {
var range = textBox.createTextRange();
range.collapse(true);
range.moveEnd('character', cursorPosition);
range.moveStart('character', cursorPosition);
range.select();
}
The first part is how you are supposed to do it according to W3C and Mozilla, Opera, Apple – the standard-compliant way. The second is how you do it in Internet Explorer – the happy fellows at Microsoft had better ideas what is good for you. So here, you see which function is there available for you, and you use it. Simple. I had some trouble calculating the right position, because I was counting the bytes of UTF-8 encoded text, instead of counting the unicode characters, and the "\r\n" newlines should be counted as one character, but that's details. It basically works. It even scrolls the textarea in Internet Explorer! Who'd have thought.
Scrolling
Unfortunately, it doesn't scroll in the standard-compliant browsers. We have to do it ourselves. You can set the scroll position of an HTML element using its scrollTop JavaScript property. It takes the offset from the top of the element, in pixels. Yes, pixels, what did you expect, EMs? So, the first thing is calculating the position of the line we want. We will need the line height of the textarea's font, and we will multiply it by the number of the line we want. Easy. But wait, lines can wrap! So we also need to know the character width of the font, and the width of the textarea, and see where the lines wrap… no, wait, they wrap at word boundaries! so we can scan the text, determine where the last word of a line is… wait a moment, different browsers do it differently! Some will wrap only on spaces, others will also wrap on punctuation… We are doomed.
Is there no hope? There might be. I cheated. I created an additional HTML element, set the font, the line height, the width and the padding to the same values as the textarea has. Then I put all the text that was supposed to be above the fold into it. Bang! You've got the pixel height! It's the height of that element! The browser has calculated it for you, using whatever broken algorithm it has in that specific version. You just have to read it from that element, and qucikly remove it, so that the users don't notice. They never notice anyways. So here is the quick and dirty code that does what I described:
var scrollPre = document.getElementById("textdiv");
var style = window.getComputedStyle(textBox, '');
scrollPre.style.lineHeight = style.lineHeight;
scrollPre.style.fontFamily = style.fontFamily;
scrollPre.style.fontSize = style.fontSize;
scrollPre.style.padding = 0;
scrollPre.style.letterSpacing = style.letterSpacing;
scrollPre.style.border = style.border;
scrollPre.style.outline = style.outline;
scrollPre.style.overflow = 'scroll';
try { scrollPre.style.whiteSpace = "-moz-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "-o-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "pre-wrap" } catch(e) {};
scrollPre.textContent = "%s"; // put your text here
textBox.scrollTop = scrollPre.scrollHeight;
scrollPre.textContent = "";
I didn't play with creating the element with JavaScript, I had an empty one generated together with the editor form. Not important. Note, that the script needs the text you have above the fold in your textarea. And it has to be properly backslash-escaped and html-quoted, obviously – little details, but how important. In the final version you probably don't want that – you will want to read those lines from the textare itself, I was just too lazy to do it in this proof of concept. Also, note how great fun the browser writers give you when it comes to the white-space CSS attribute. Originality is so important, each browser does it differently. That's what I call branding.
All Together
So how does it work together? Try it. Double-click on any of the paragraphs of text in this article, the editor should come up scrolled to the right spot, with a little luck. Got any comments, questions, improvements? Please e-mail me (Radomir Dopieralski) or leave them here below the line. I hope this will prove useful to the future wiki coders.
Pure JavaScript
Update: here's a pure JavaScript implementation. It will scroll the textarea to the line passed in the "chunk" part of the URL, for example
http://example.com/wiki#12 will make it jump to line 12 (counting from 0).
var jumpLine = 0+document.location.hash.substring(1);
var textBox = document.getElementById('editortext');
var textLines = textBox.textContent.match(/(.*\n)/g);
var scrolledText = '';
for (var i = 0; i < textLines.length && i < jumpLine; ++i) {
scrolledText += textLines[i];
}
textBox.focus();
if (textBox.setSelectionRange) {
textBox.setSelectionRange(scrolledText.length, scrolledText.length);
var scrollPre = document.createElement('pre');
textBox.parentNode.appendChild(scrollPre);
var style = window.getComputedStyle(textBox, '');
scrollPre.style.lineHeight = style.lineHeight;
scrollPre.style.fontFamily = style.fontFamily;
scrollPre.style.fontSize = style.fontSize;
scrollPre.style.padding = 0;
scrollPre.style.border = style.border;
scrollPre.style.outline = style.outline;
scrollPre.style.overflow = 'scroll';
scrollPre.style.letterSpacing = style.letterSpacing;
try { scrollPre.style.whiteSpace = "-moz-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "-o-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "pre-wrap" } catch(e) {};
scrollPre.textContent = scrolledText;
textBox.scrollTop = scrollPre.scrollHeight;
scrollPre.parentNode.removeChild(scrollPre);
} else if (textBox.createTextRange) {
var range = textBox.createTextRange();
range.collapse(true);
range.moveEnd('character', scrolledText.length);
range.moveStart('character', scrolledText.length);
range.select();
}
All you need now is a wiki engine that somehow puts the information about line numbers in the HTML, and a short piece of JavaScript that will use that information to define ondblclick callbacks for the
various HTML elements. Here is one for Hatta (the %s is replaced by an edit link):
var paragraphs = document.getElementsByTagName('p');
for (var i = 0; i < paragraphs.length; ++i) {
if (paragraphs[i].id && paragraphs[i].id.substring(0, 5) == 'line_') {
paragraphs[i].ondblclick = function () {
document.location.href = '%s#'+this.id.replace('line_', '');
};
}
}
and here is a slightly modified solution for the MoinMoin wiki:
// Hook to the onload callback, execute any callback that was there before.
var savedOnLoad = window.onLoad;
window.onload = function () {
try { savedOnLoad() } catch(e) {};
// This is the function that registers double clicks.
function setCallback(node, line) {
if (!node.scrollLine) {
// Don't change the line number if there is one already.
node.scrollLine = 0+line-2; // MoinMoin seems to be off by 2 lines
}
if (node.scrollLine || node.scrollLine == 0) {
node.ondblclick = function () {
// Only change the query part of the URL
document.location.search = '?action=edit&line='+this.scrollLine;
};
}
};
// Check all the SPAN tags
var marks = document.getElementsByTagName('span');
for (var i = 0; i < marks.length; ++i) {
var mark = marks[i]
if (mark.id && mark.id.substring(0, 5) == 'line-') {
// We have a line mark SPAN, get the line number from it
var line = mark.id.replace('line-', '');
if (mark.parentNode.tagName == 'DIV') {
// If the SPAN is inside a DIV, set callback on the previous
// non-SPAN element in the same DIV.
var child = mark.parentNode.firstChild;
var last_child = null;
while (child) {
if (child == mark && last_child) {
setCallback(last_child, line);
}
if (child.tagName && child.tagName != 'SPAN') {
// Only consider tag that are not SPANs
last_child = child;
}
child = child.nextSibling;
}
} else {
// If the SPAN is inside a P, simply set callback on that P
setCallback(mark.parentNode, line);
}
}
};
// See if we are in editor and if there is line number specified in URL
var textBox = document.getElementById('editor-textarea');
var lineMatch = document.location.search.match(/line=(\d*)/);
if (textBox && lineMatch) {
// Calculate the cursor position
var textLines = textBox.textContent.match(/(.*\n)/g);
var scrolledText = '';
var jumpLine = lineMatch[1];
for (var i = 0; i < textLines.length && i < jumpLine; ++i) {
scrolledText += textLines[i];
}
textBox.focus();
if (textBox.setSelectionRange) {
// Standard-compliant browsers
// Move the cursor
textBox.setSelectionRange(scrolledText.length, scrolledText.length);
// Calculate how far to scroll, by putting the text that is to be
// above the fold in a DIV, and checking the DIV's height.
var scrollPre = document.createElement('pre');
textBox.parentNode.appendChild(scrollPre);
var style = window.getComputedStyle(textBox, '');
scrollPre.style.lineHeight = style.lineHeight;
scrollPre.style.fontFamily = style.fontFamily;
scrollPre.style.fontSize = style.fontSize;
scrollPre.style.padding = 0;
scrollPre.style.letterSpacing = style.letterSpacing;
scrollPre.style.border = style.border;
scrollPre.style.outline = style.outline;
scrollPre.style.overflow = 'scroll';
// Different browsers calls this value differently:
try { scrollPre.style.whiteSpace = "-moz-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "-o-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "-pre-wrap" } catch(e) {};
try { scrollPre.style.whiteSpace = "pre-wrap" } catch(e) {};
scrollPre.textContent = scrolledText;
textBox.scrollTop = scrollPre.scrollHeight-100;
scrollPre.parentNode.removeChild(scrollPre);
} else if (textBox.createTextRange) {
// Microsoft Internet Explorer
// We don't need to scroll, it will do it automatically, just move
// the cursor.
var range = textBox.createTextRange();
range.collapse(true);
range.moveEnd('character', scrolledText.length);
range.moveStart('character', scrolledText.length);
range.select();
}
}
};
![[Home]](/+download/logo.png)