Wrapping Text in Drafts

Last summer I created a Drafts action for a user to allow them to mark some text in IA writer’s Markdown syntax for a highlight. Now my solution was not the first solution offered, but it was a little different to the others, and I have been meaning to write it up for quite some time now to go into a bit more detail about how it works and the benefits it provides over other, existing solutions.

The Fundamental Challenge

The crux of the issue to be solved was that the user has some text that they wanted to wrap (or delimit) in pairs of sequential equality symbols. For example, they might opt to mark the word “emphasise” in the sentence below.

I would like to emphasise a word by marking it as highlighted.

The desired text would then simply look as follows.

I would like to ==emphasise== a word by marking it as highlighted.

I will go through some of the basics of how existing actions can solve this, followed by a deep dive into how I solved it, and you can of course download the action I created to address the original request.

A Typical Approach

The typical approach to tackling this would be to modify an existing action that wraps text. The action to bold text being an obvious choice as it also delimits with pairs of identical characters. This action then uses a script step to replace the current selection with the current selection delimited by pairs of equality symbols.

const sel = editor.getSelectedText();
const [st, len] = editor.getSelectedRange();

if (!sel || sel.length == 0) {
  editor.setSelectedText("==");
  editor.setSelectedRange(st + 2,0);
}
else {
  editor.setSelectedText("==" + sel + "==");
  editor.setSelectedRange(st + len + 4,0);
}

This code will wrap any selected text, but also will insert two equality signs if there is no selection - so that allows you to turn on/off highlighting as you go. Succinct and relatively simple.

An Enhanced Approach

There are upgraded examples of this where the delimiter is specified once and changes for the selection calculations so it can be of any length, plus some consideration of what the current selection is, removing any existing delimiters that have been selected, thus allowing you to effectively toggle the highlighting on and off.

const markup = "==";
const sel = editor.getSelectedText();
const [st, len] = editor.getSelectedRange();

if (sel.includes(markup)){
	editor.setSelectedText(sel.replaceAll(markup,""));
} else {
	if (!sel || sel.length == 0) {
	  editor.setSelectedText(markup);
	  editor.setSelectedRange(st + markup.length,0);
	}
	else {
	  editor.setSelectedText(markup + sel + markup);
	  editor.setSelectedRange(st + len + (markup.length * 2),0);
	}
}

A small variation on this was the starting point for one of the earlier solutions on the forum request. However, one point to note about the solution is that the code above sets the position of the cursor to after the suffix when adding delimiters, but when the wrapping text is toggled off, the text selection includes the whole text. Ideally an additional line should have been included to position the cursor.

if (sel.includes(markup)){
	editor.setSelectedText(sel.replaceAll(markup,""));
	editor.setSelectedRange(editor.getSelectedRange()[0] + editor.getSelectedRange()[1],0);

This solution was followed by a novel and simpler solution (both in complexity and functionality) based on the clipboard - which is handy if you happen to want to use the clipboard, but that wasn’t in the original request, and I think the above enhanced approach has a broader use case in general with a lot more power and flexibility to it.

An Alternative Solution

In creating my own solution, I broadened the scope to consider text wrapping that did not necessarily include identical delimiters (e.g. angle brackets “<” & “>”), or even delimiters of the same length (e.g. HTML comment tags (valid in Markdown) “<!--” and “-->”). In fact, I also wanted to be able to deal with cases where “half” a delimiter was required (e.g. a “> ” to prefix the start of a line as a quote in Markdown).

Taking that into consideration, the wrapping action includes three template tag definitions.

  • WRAP
  • PREFIX
  • SUFFIX

The WRAP tag defines a single delimiter that will be used to wrap the text, and so to meet the requirement in the forum post, this was populated with “==”. However, if this is blank, the action will attempt to wrap the text with the content of the PREFIX and SUFFIX template tags (and you could for example leave the SUFFIX template tag blank if you just want a prefix). By taking these settings out into template tag action steps, I have found it is generally easier for users to modify them as they don’t have to dig into the code - which can be a scary proposition for some Drafts users.

Of course, the script step that follows the template tag steps is where the smarts of the action are contained, and like the script actions described above, it is where the insert (or indeed toggling) occurs.

The script starts out by populating a strPrefix and strSuffix variable with the appropriate values form the template tags.

// Set up variables for prefix and suffix
let strPrefix;
let strSuffix;

if(draft.getTemplateTag("WRAP") == "")
{
	// No WRAP tag defined, so we must use PREFIX and SUFFIX
	// One of both of them may not be defined in which case the prefix 
	// and/or suffix will be an empty string, allowing just for prefixes
	// or just for suffixes.
	strPrefix = draft.getTemplateTag("PREFIX");
	strSuffix = draft.getTemplateTag("SUFFIX");
}
else
{
	// Wrap tag is defined, so prefix and suffix are identical
	strPrefix = draft.getTemplateTag("WRAP");
	strSuffix = strPrefix;
}

The next section of the code checks the current selection to determine if the text we are wrapping/prefixing/suffixing is already wrapped/prefixed/suffixed. It not only checks the current selection, but it will also check if the selection extended by the number of characters for the wrap/prefix/suffix matches this. So whether you have selected the text and the additional characters, or just the text, the script will check for both and then automatically extend your range if necessary to include those additional characters.

// Get selection range details from editor
let rngSelected = editor.getSelectedRange();

// Check if we are adding or undoing the insertion
// If the text before and after is the prefix/suffix, extend the selection
let strBeforeSelection = editor.getTextInRange(rngSelected[0] - strPrefix.length, strPrefix.length);
let strAfterSelection = editor.getTextInRange(rngSelected[0] + rngSelected[1], strSuffix.length);
if(strBeforeSelection == strPrefix && strAfterSelection == strSuffix)
{
	// Extend the range and update the variable for the selection range
	editor.setSelectedRange(rngSelected[0] - strPrefix.length, strPrefix.length + rngSelected[1] + strSuffix.length);
	rngSelected = editor.getSelectedRange();
}

Once the text selection is confirmed as a best match, the selection content is stored in the variable strSelection.

If the selection starts and ends with the defined prefix and suffix respectively (which remember would be the same if WRAP was set), then the selection is replaced with just the text, otherwise, the selection is replaced with the selection plus the prefix and suffix. In each case, the cursor is positioned after the text.

// Get selection content
let strSelection = editor.getSelectedText();

if(strSelection.startsWith(strPrefix) && strSelection.endsWith(strSuffix))
{
	// Remove the selection and position the cursor
	editor.setSelectedText(strSelection.substring(strPrefix.length, strSelection.length - strSuffix.length ));
	rngSelected = editor.getSelectedRange();
	editor.setSelectedRange(rngSelected[0] + rngSelected[1], 0);
}
else
{
	//Add the selection

	// Replace the selection and position the cursor.
	if (!strSelection || strSelection.length == 0)
	{
		editor.setSelectedText(strPrefix + strSuffix);
		editor.setSelectedRange(rngSelected[0] + strPrefix.length, 0);
	}
	else
	{
		editor.setSelectedText(strPrefix + strSelection + strSuffix);
		editor.setSelectedRange(rngSelected[0] + rngSelected[1] + strPrefix.length + strSuffix.length, 0);
	}
}

Note, one difference between this code and the previous solutions is that I have not opted to insert a single wrap when no text is selected. Rather the prefix and suffix are always inserted. This is for two reasons. The first being that I don’t have to constantly check back an arbitrary distance to determine if a prefix or suffix is required, and second, the prefix and suffix always need to be there, so I see no benefit in having to trigger the action twice in such circumstances (once to open + once to close vs. once to open & close).

Finally, I activate Drafts for editing. I find invariably that this is what I want to do when I am modifying text like this. By default, the editing pane is not in focus if I trigger an action from the action list, keyboard action bar, or search, rather than with a keyboard shortcut. Adding this line at the end removes that little bit of friction.

// Activate the editor for the selection
editor.activate();

In summary, the script step looks like this.

// Set up variables for prefix and suffix
let strPrefix;
let strSuffix;

if(draft.getTemplateTag("WRAP") == "")
{
	// No WRAP tag defined, so we must use PREFIX and SUFFIX
	// One of both of them may not be defined in which case the prefix 
	// and/or suffix will be an empty string, allowing just for prefixes
	// or just for suffixes.
	strPrefix = draft.getTemplateTag("PREFIX");
	strSuffix = draft.getTemplateTag("SUFFIX");
}
else
{
	// Wrap tag is defined, so prefix and suffix are identical
	strPrefix = draft.getTemplateTag("WRAP");
	strSuffix = strPrefix;
}

// Get selection range details from editor
let rngSelected = editor.getSelectedRange();

// Check if we are adding or undoing the insertion
// If the text before and after is the prefix/suffix, extend the selection
let strBeforeSelection = editor.getTextInRange(rngSelected[0] - strPrefix.length, strPrefix.length);
let strAfterSelection = editor.getTextInRange(rngSelected[0] + rngSelected[1], strSuffix.length);
if(strBeforeSelection == strPrefix && strAfterSelection == strSuffix)
{
	// Extend the range and update the variable for the selection range
	editor.setSelectedRange(rngSelected[0] - strPrefix.length, strPrefix.length + rngSelected[1] + strSuffix.length);
	rngSelected = editor.getSelectedRange();
}

// Get selection content
let strSelection = editor.getSelectedText();

if(strSelection.startsWith(strPrefix) && strSelection.endsWith(strSuffix))
{
	// Remove the selection and position the cursor
	editor.setSelectedText(strSelection.substring(strPrefix.length, strSelection.length - strSuffix.length ));
	rngSelected = editor.getSelectedRange();
	editor.setSelectedRange(rngSelected[0] + rngSelected[1], 0);
}
else
{
	//Add the selection

	// Replace the selection and position the cursor.
	if (!strSelection || strSelection.length == 0)
	{
		editor.setSelectedText(strPrefix + strSuffix);
		editor.setSelectedRange(rngSelected[0] + strPrefix.length, 0);
	}
	else
	{
		editor.setSelectedText(strPrefix + strSelection + strSuffix);
		editor.setSelectedRange(rngSelected[0] + rngSelected[1] + strPrefix.length + strSuffix.length, 0);
	}
}

// Activate the editor for the selection
editor.activate();

Reuse

This action was built with reuse in mind, but not strictly applied for re-use when I posted it. That was on purpose, and part of the reason I wanted to write it up. If you want to reuse this action for other text wrapping (e.g. bold, italics, etc.), then you have three options.

Action Duplication

The first, and worst, is to simply duplicate the action. This means that if you later want to modify the code, you need to modify it separately in each action. That isn’t smart, but it is easy, and who knows if you’ll ever want to update the code - it works as it is after all.

The second and third options centralise the script element, which is a smarter approach, if a little more involved.

Centralised Action

You could take the existing action, disable/delete the template tags, and then create other actions that set those template tags up prior to calling the action containing the script, using the Include Action action step. This means you have just one central action in which to update the scripting.

Centralised Script

The other option, would be to put the script into a file that the actions read in and execute. The advantage of this is that you keep your actions listing a little cleaner, but really there’s not much in it, and you can hide actions away. If you did want to do this, you would need to set it up something like this:

  1. Place the script in your iCloud Drive/Drafts/Library/Scripts folder as say wraptext.js.
  2. Replace the script content in the actions Script step with require('wraptext.js');

In future, rather than maintaining the script within the script step of the action, you maintain it in the wraptext.js file.

Conclusion

I believe the benefits of my action over the other approaches can be summarised as follows:

  1. It accommodates a smarter selection of text (the text, or the text and the prefix/suffix) for both toggling on, and toggling off.
  2. It places the cursor in a logical position both when adding and when removing the prefix/suffix.
  3. It is easily customisable, with no code to amend.
  4. It can be used for varying prefixes and suffixes, including none of one of them, as well as identical ones.
  5. The editor activates at the end to allow you to immediately type if you triggered the action using a method that was not a keyboard shortcut.

To reiterate, there is nothing wrong with the existing actions and solutions. They work great … for what they were written to do. My action simply does a bit more and tries to do some more things to make it nicer to work with. I am also positive that further enhancements could be made to my action to make it even smarter.

Why not try the action out for yourself by replacing some of the standard text wrapping actions with it and see what you think?

Author: Stephen Millard
Tags: | drafts |

Buy me a coffeeBuy me a coffee



Related posts that you may also like to read