Lines and Drafts

If you’re a Drafts user, you’ve probably noticed this oddity in how it displays—or, more accurately, doesn’t display—the trailing final line number for drafts with a certain type of content. Here, for example, are two ever-so-slightly different versions of a draft:

Drafts lines

Where is the extra character in the version on the right? It’s a trailing linefeed character after the fifth line. If I had timed the screenshots to show the blinking cursor at the end of the file, you’d see that the end of the file on the left is just after the final e, while the end of the file on the right is at the start of the sixth line. But Drafts doesn’t consider that sixth proto-line worthy of numbering.1

Generally speaking, I agree with Greg Pierce’s decision on this. A linefeed character at the end of a file doesn’t really mean there’s another line there. But there are times when I wish the line number was there. For example, when I’m writing a blog post and there are Markdown reference links at the bottom of the draft, I sometimes look at the last number and wonder if there’s a linefeed at the end of it. If there isn’t, that means I’ve screwed something up and the next reference link I add (which is done through this Drafts action) will be tacked onto the end of the last line instead of being on a line of its own.

Draft blog post

Another aspect of how Drafts handles lines came up last night and is the real reason for this post. I was trying to work out an answer to John Catalano’s question about incrementing the number of leading hashes (to denote a Markdown header of a particular level) to the current line. I misread the question and wrote a JavaScript action that would increment the number of hashes in all the lines of the current selection (which would reduce to the cursor’s current line if there’s no selection). When I looked back at the question this morning, I saw my mistake—and that Greg had given John a better answer—but I kept thinking about my solution and how part of it could be reused to handle many situations in which a series of lines need to be processed.

The script I ended up with was an improvement on the one you’ll see in my forum answer, but it does basically the same thing: increment the number of hashes in the selected lines until it gets to three hashes, at which point it resets to zero. Here’s the script:

 1:  // Extend selection to full lines without final trailing newline
 2:  var [start, len] = editor.getSelectedLineRange();
 3:  var full = editor.getTextInRange(start, len);
 4:  full = full.replace(/\n$/, "");
 5:  len = full.length;
 6:  editor.setSelectedRange(start, len);
 7:  var lines = full.split("\n");
 9:  // Adjust the leading hashes for each line in turn
10:  // Skip empty lines
11:  for (var i=0; i<lines.length; i++) {
12:    if (lines[i].length == 0) {
13:      continue;
14:    } else if (lines[i].substring(0, 4) == "### ") {
15:      lines[i] = lines[i].substring(4);
16:    } else if (lines[i].substring(0, 1) == "#") {
17:      lines[i] = "#" + lines[i];
18:    } else {
19:      lines[i] = "# " + lines[i];
20:    }
21:  }
23:  // Rejoin the lines and replace the selected text in the editor
24:  var s = lines.join("\n");
25:  editor.setSelectedText(s);

In a nutshell, the first stanza extends the selection to encompass all the lines that are at least partially selected and then splits that into an array, with each element consisting of one line of text (with no trailing linefeed). The second stanza operates on each of those lines in turn. The third stanza then glues the lines back together with linefeed characters and replaces the selected lines in the editor with the glued-together string.

The part I really care about—the part I think is reusable—is the first stanza, Lines 1–7. There are many situations in which appending, prepending, or otherwise transforming a series of lines is useful, and this section of code is a nice setup for that kind of operation.

It starts by calling the getSelectedLineRange function of the global editor object on Line 2. According to the documentation, this

Get[s] the current selected text range extended to the beginning and end of the lines it encompasses.

The value returned is a two-element array consisting of the starting character position and the length of the text range. The key to getSelectedLineRange, and what makes it different from getSelectedRange, is that the starting character position is the beginning of the line containing the start of the selection and the range extends to the end of the line containing the end of the selection.

If the action is called with this selection

Drafts selection

start will be 11 (there are 11 characters [remember the linefeed] before the S at the start of the second line) and len will be 35 (12 from the second line, 11 from the third, and 12 from the fourth). Note that the end of the fourth line is just after the linefeed that separates it from the fifth.

Given that all the other linefeeds in this range are included, it’s consistent to include the one at the end of the last line in the range. As we’ll see, though, there’s a situation for which the last character in the range won’t be a linefeed, and we’ll have to code defensively to cover that situation.

Line 3 uses getTextInRange to grab the range we just described and put it in the variable full.

Looking ahead to Line 7, we see that we’re going to use split("\n") to convert full into the array lines. Because we don’t want lines to include an empty element at the end, we should strip off any final linefeed from full before running split. That’s what the replace in Line 4 deals with. It insures that the last character of full is removed if it’s a linefeed.

Why can’t we just remove the last character of full without checking for what it is? When won’t full have a linefeed at the end? That’s where the case we looked at back at the beginning of this post comes in. If the last line of the draft doesn’t end with a linefeed, and that line is part of the selection, then full won’t end with a linefeed.

Line 5 adjusts the value of len to account for the (usually) new length of full, and Line 6 selects all the text we’re going to be transforming.2 Finally, Line 7 splits full into lines.

After the operations on lines is done, we use join("\n") to put lines back together into a single string (Line 24) and replace the selection in the editor with that string (Line 25).

If you’re thinking this is one of those posts I write mostly for myself, you’re right. With luck, writing this will help me remember to use this split-operate-rejoin process then next time I need to process a series of lines in Drafts. If it helps you too, that’s a bonus.

  1. In the Editor settings, Drafts calls the little numbers in the left margin paragraph numbers instead of line numbers, which makes sense because of the way word wrapping works. But if you’re writing code or other structured text, it’s better to think of them as line numbers. 

  2. This selection isn’t strictly necessary, but it makes the later substitution of the transformed text into the editor easy, and I like to highlight the text being worked on.