You might have seen a new feature arrive in the editor this week - the markdown toolbar:
Feature update: Markdown Toolbar
Amy Lin for The DEV Team ・ Nov 29 '21
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
- Core functionality: insert and undo formatting
- Thinking about keyboard interactions
- Changes to image upload
- Final thoughts
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
});
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:
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****
).
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".
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
}
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.
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.
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
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
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,
})
...
});
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"
/>
When the image upload completes, we add text to this element by calling
document.getElementById('upload-success-info').innerText = 'image upload complete';
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.