How we made the markdown toolbar

Suzanne Aitchison - Dec 2 '21 - - Dev Community

You might have seen a new feature arrive in the editor this week - the markdown toolbar:

As a follow up to Amy's post, I wanted to share a little bit about how we approached the development of the toolbar component, and some of the technical considerations we've had in mind during the implementation.

Quick contents:

Storybook for sandboxed development

As much as possible, we like to create features in small incremental Pull Requests. It helps us make PRs more easily reviewable, and allows us to get feedback and adjust course as early in an implementation as possible.

However, we don't want to ship incomplete features to DEV or any other Forem! Instead, we built out the markdown toolbar in our Storybook. This gave us a sandboxed environment where we had access to all of our design system classes, components, etc, without having to actually add the toolbar to the editor (so now you know where to look if you want to creep on new frontend features in development 🤓).

There were a couple of extra benefits to this approach, namely:

  • We use @storybook/addon-a11y which gave us continuous accessibility feedback as we built the component
  • We were able to easily share the "work in progress" across our team, since although the code wasn't "live" in the app, it was "live" in Storybook

If you're new to Storybook, I'd recommend checking out this talk from @nickytonline

Core functionality: insert and undo formatting

The core functionality of the toolbar is to insert and remove formatting, and you can find the code responsible for this in markdownSyntaxFormatters.js. The logic is all contained in this helper file, keeping it separate from the Preact component itself, to allow for better readability and testability (there are well over a hundred tests for this utility file!).

Grouping formatters

We grouped the formatters broadly into two categories - inline (e.g. **bold**, _italic_) and multi-line (e.g. code blocks, lists). In the end, most of the formatters rely on two core functions: undoOrAddFormattingForInlineSyntax, and undoOrAddFormattingForMultilineSyntax. This means that most formatters can call the same function, just passing along what prefix and suffix is expected, e.g. the bold formatter looks like:

undoOrAddFormattingForInlineSyntax({
  selectionStart, // where the user's selected text starts
  selectionEnd, // where the user's selected text ends 
  value, // the current text area value
  prefix: '**', // the formatting expected before selection
  suffix: '**', // the formatting expected after selection
});
Enter fullscreen mode Exit fullscreen mode

Outliers to the groupings

There are a couple of formatters which don't fall neatly into the two groups mentioned above, namely Heading and Link.

The Heading formatter has special functionality, where the heading level is incremented on each click, up until a maximum of heading level 4, after which it removes the formatting completely.

Similarly the Link formatter adjusts its behaviour depending on whether your selected text is a URL or not. Since they don't readily fit into the undoOrAddFormattingForInlineSyntax or undoOrAddFormattingForMultilineSyntax functions, they have their own custom code instead.

Allowing for formatting to be removed

On face value, the core function of handling a button press is pretty straightforward - add the prefix before the selected text, and the suffix after it. However, we had a few additional cases to consider, for example:

Editor text

If the user's selected text is "hello world", but the characters immediately before and after the selection match the prefix/suffix, we want to remove the formatting. In this example above, the highlighted "hello world" should remain, and the stars on either side should be removed (rather than formatting it as bold for a second time and producing ****hello world****).

Editor text with

If the user's selected text includes the prefix/suffix, we also want to remove the formatting. In the example here, **hello world** should become "hello world".

A markdown link in the editor, displayed three ways. One fully highlighted, one with the URL only highlighted, and one with the link description only highlighted

Both of the above considerations become more complex in certain cases like links, where the user's selected text could be the URL, or the link description, or the entire format from beginning to end. For example, given the link [my link text](http://myurl.com), we want to remove the whole link formatting whether the user has selected "my link text", or "http://myurl.com", or the full link including both parts.

The upshot is that we need to check both the selected text, but also the text before and after the current selection before we decide what to do with the button press. We've favoured being a bit more verbose in the code to be clear about what we're doing at each stage of these checks, for example:

const selectedTextAlreadyFormatted =
    selectedText.slice(0, prefixLength) === prefix &&
    selectedText.slice(-1 * suffixLength) === suffix;

if (selectedTextAlreadyFormatted) {
  // return the appropriate result
}

const surroundingTextHasFormatting =
    textBeforeSelection.substring(textBeforeSelection.length - prefixLength) ===
      prefix && textAfterSelection.substring(0, suffixLength) === suffix;

if (surroundingTextHasFormatting) {
  // return the appropriate result
}
Enter fullscreen mode Exit fullscreen mode

It would definitely be possible to make our formatter code terser, but we've veered on the side of readability so that the code is more maintainable and easier to contribute to.

Maintaining correct cursor position/text selection

The final consideration on button press is making sure the user's text selection remains consistent after we use a formatter.

Illustration showing that once we add two stars before a word to make it bold, its character position has increased by 2

If the user has text selected, we want to make sure it stays selected after adding/removing the formatting. Given the length of the text area value changes after adding/removing the formatting (e.g. adding or removing "**"), this means we have to calculate the indexes of the selection's new start and end point.

If the user doesn't have text selected, we want to make sure their cursor is placed inside the new formatting, ready to keep typing.

Text that says

In cases like links, we adjust where we place the cursor depending on whether a link description or URL already exists. For example, if you select the text http://myurl.com and press the link button, you'll see this update to [](http://myurl.com) and notice your cursor is placed inside the square brackets, ready to write the description. Conversely, if your selected text was "my awesome portfolio", you'll see [my awesome portfolio](url), with the placeholder "url" selected, ready for you to replace it with the link's actual URL.

In the end then, all of our formatters return an object detailing all the information the Preact component needs to update the text area, including the properties:


editSelectionStart // The start index of the text we will replace
editSelectionEnd // The end index of the text we will replace
replaceSelectionWith: // The new text to place between the editSelectionStart and editSelectionEnd
newCursorStart // Start index of new cursor selection
newCursorEnd // End index of new cursor selection
Enter fullscreen mode Exit fullscreen mode

Thinking about keyboard interactions

I'll preface this section by mentioning that there is a known bug on our editor page, in that there is a focus trap if you press the Tab key and activate the tags input. Development to replace the tags autosuggest component with an accessible version is underway and we aim to have this resolved very soon.

Roving tabindex

The markdown toolbar follows the toolbar authoring practices, and a substantial part of this is making it appropriately navigable by keyboard.

Once your focus is inside the toolbar, it's navigable by Left/Right Arrow key, and you'll see that the focus cycles without interruption - e.g. if you press LeftArrow when focused on the 'Bold' button, focus will move to the overflow menu (the last item on the right).

We use the roving tabindex technique to achieve this, managing the buttons' tabindex attribute in Javascript. I won't go into too much detail on that implementation here (maybe a follow up post!), but the result is that the controls are effectively grouped together.

Accessible tooltips

Screenshot of the toolbar with the Heading button focused, and a tooltip reading

Prior to this toolbar work, the only tooltips we had in the codebase were "hover only", meaning they can't be triggered by keyboard. For this reason, we haven't used tooltips much to convey essential information, since not all users would be able to benefit from it. However, the toolbar design called for some extra detail for all users, to make sure the buttons' functions could be understood.

We've updated our Button component to accept a tooltip now, and by default this tooltip forms part of the button's accessible name (by including the text inside the button, even if it is visually hidden). The tooltip is shown on hover and on focus, meaning that the keyboard can trigger its appearance. We've also made sure that a user can temporarily dismiss the tooltip by pressing Escape, since it could be appearing over some other content and getting in the way!

Keyboard shortcuts

Some of the formatters also have keyboard shortcuts, which we implemented using a KeyboardShortcuts component we already use throughout the app.

One thing that came to light quickly, however, was that our KeyboardShortcuts component treated the macOS cmd key and the ctrl key interchangeably. This meant that on macOS, pressing ctrl + b would activate the bold formatter the same as cmd + b, when the standard behaviour would be for the cursor to move back one space. We've now resolved this issue across the codebase.

Another issue quickly raised by DEV community members after launch was that we'd neglected to call event.preventDefault() on a shortcut key press, with the unfortunate side effect that some fairly disruptive browser shortcuts were also being triggered by our shortcuts (for example, cmd + u in Firefox was adding underline formatting but also opening 'view source' for the page 🙈). Thanks to the quick feedback from the community, we were able to resolve this within hours of launch.

Changes to image upload

The final aspect of the toolbar development was some changes to the image upload flow.

Styling the file input

Styling file input selector buttons is notoriously tricky, and to make sure we could maintain the look and feel of our other toolbar buttons, we instead have relied on a visually hidden file input, with a separate button in the toolbar, which activates that hidden file input when it's clicked.

Making uploads cancelable

Previously a user couldn't cancel an in-progress image upload, but we've changed that! We've achieved this by making use of the AbortSignal interface.

When an upload begins, we create an AbortRequestController, and pass its "signal" to our helper function which makes the network request via fetch:

const startNewRequest = (e) => {
  const controller = new AbortController();
  setAbortRequestController(controller);
  handleInsertionImageUpload(e, controller.signal);
};

// Triggered by handleInsertionImageUpload
export function generateMainImage({ payload, successCb, failureCb, signal }) {
  fetch('/image_uploads', {
    method: 'POST',
    headers: {
      'X-CSRF-Token': window.csrfToken,
    },
    body: generateUploadFormdata(payload),
    credentials: 'same-origin',
    signal,
  })
...
});
Enter fullscreen mode Exit fullscreen mode

To cancel the in-progress request we can call abortRequestController.abort(), and - tada - it's cancelled!

More feedback for screen reader users

Prior to the toolbar work, there wasn't much feedback for screen reader users when using the image upload functionality. The generated image markdown, or any error, would appear next to the image upload button, but unless you could visually see that appearing, there was no other prompt to let you know the outcome.

We now let users know when an upload successfully completes, via an aria-live region which looks like this:

<div
  id="upload-success-info"
  aria-live="polite"
  className="screen-reader-only"
/>
Enter fullscreen mode Exit fullscreen mode

When the image upload completes, we add text to this element by calling

document.getElementById('upload-success-info').innerText = 'image upload complete';
Enter fullscreen mode Exit fullscreen mode

which is then announced to screen reader users.

In the case of an error, we use our Snackbar component which uses a similar mechanism to make an announcement to screen reader users as it appears.

Final thoughts

I mentioned it further up, but a big shout out to the DEV community for quickly highlighting some issues with the toolbar when it went live. Thanks to your help, we were able to push fixes the same day it went live, and make the feature work better for others.

We're continuing to keep track of potential future enhancements, and you can see the current status on the GitHub epic.

If you'd like to dig deeper into the code, check out the Toolbar issue on GitHub, and its related pull requests.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .