Building a clone of dev.to's markdown editor with SvelteKit and TypeScript
Tags: webdev, svelte, typescript, javascript
Published: 2023-02-14 12:28:54

## Update

---
**Some issues raised when this article was published have been fixed. Cursor positioning now works, duplicate commands are now prevented and other features implemented. Kindly check this project's repository for updated source code.**

## Motivation

---

Rust has been of huge interest for a while but I couldn't squeeze out time to really experiment with it. However, I became resolute to really pick up the language for backend development and subsequently, frontend development using WebAssembly. Since I learn by doing, I picked up the challenge to build Content Management System (CMS) using Rust's popular, truly async, and scalable web frameworks such as actix-web, axum, and warp. While building the APIs with rust, I was building the frontend with SvelteKit. Then, a need arose. I needed to build a great markdown editor, just like dev.to's so as to seamlessly write markdowns and preview them at will. The outcome of the exercise is roughly what this article is about.

## Tech Stack

---

For this project, we'll heavily be using:

- [HTML](https://developer.mozilla.org/en-US/docs/Glossary/HTML5)
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS)
- [TypeScript](https://www.typescriptlang.org/)
- [SvelteKit](https://kit.svelte.dev/) (v1.5.0)
- [Fontawesome](https://fontawesome.com/) (v6.2.0) for icons
- [Highlight.js](https://highlightjs.org/) (v11.7.0) for syntax highlighting.
- [Marked.js](https://marked.js.org/) (v4.2.1) for parsing markdown to [HTML](https://developer.mozilla.org/en-US/docs/Glossary/HTML5).

## Assumption

---

It is assumed that you are familiar with any JavaScript-based modern frontend web framework or library and some TypeScript.

## Source code and a live version

This tutorial's source code can be accessed here:

{% github  Sirneij/devto-editor-clone  %}

The finished project was deployed on vercel and can be accessed [here (https://devto-editor-clone.vercel.app/)](https://devto-editor-clone.vercel.app/).

## Implementation

---

**NOTE: I will not delve into explaining the CSS codes used. Also, SvelteKit project creation will not be discussed.**

### Step 1: Create TypeScript types and svelte stores

Since TypeScript will be heavily used, we need to create the interfaces we'll be using. Also, we need some stores to hold some data while we are in session. Therefore, create a `lib` subfolder in your `src` folder. In turn, create `types` and `stores` subdirectories in the `lib` folder. In `types`, create `article.interface.ts`, `error.interface.ts`, and `tag.interface.ts`. Also, create `editor.store.ts`, `notification.store.ts` and `tag.store.ts` in the `stores` folder. The content of each file is as follows:

```typescript
// src/lib/types/article.interface.ts
export interface EditorContent {
    content: string;
}

// src/lib/types/error.interface.ts
export interface CustomError {
    error?: string;
    id?: number;
}

// src/lib/types/tag.interface.ts
export interface Tag {
    id: string;
    name: string;
    description: string;
}

// src/lib/stores/editor.store.ts
import { writable } from 'svelte/store';

export const showPreview = writable(false);

// src/lib/stores/notification.store.ts
import { writable } from 'svelte/store';

export const notification = writable({ message: '', backgroundColor: '' });

// src/lib/stores/tag.store.ts
import { writable, type Writable } from 'svelte/store';

export const tagList: Writable<Array<string>> = writable([]);
```

Those are basic TypeScript interfaces and svelte stores. By the way, stores are like react's contextAPI, React Query, and redux. Unlike react's, svelte stores are built-in and are remarkable. We have three stores that: help toggle the Preview mode state; house the notification states; and the array of tags currently being selected respectively.

### Step 2: Add custom logic for tagging

Next up, we need to write the logic that adds a tag to the array of tags currently chosen. Like in dev.to's editor, tag suggestions logic will also be taken care of. To do these, create the `utils` subdirectory in the `lib` folder and, in turn, create a `custom` subdirectory. In `custom`, create a file, `select.custom.ts`. The content of the file will be iteratively discussed as follows:

```typescript
import { tagList } from '$lib/stores/tag.store';
import type { Tag } from '../../types/tag.interface';

/**
 * @file $lib/utils/select.custom.ts
 * @param { string } tagName - The tag to be created
 * @returns {HTMLDivElement} The `div` element created
 */
const createTag = (tagName: string): HTMLDivElement => {
    const div = document.createElement('div');
    div.classList.add('tag', tagName.trim().toLowerCase());
    const span = document.createElement('span');
    span.innerHTML = tagName.trim().toLowerCase();const removeTag = document.createElement('i');
    removeTag.classList.add('fa-solid', 'fa-close');
    div.appendChild(span);
    div.appendChild(removeTag);
    return div;
};
```

First, the `createTag` function. It takes a tag's name, and wraps it in a `div` element. For instance, let's say `rust` is the tag name, this function will create something like:

```html
<div class="tag rust">
   <span>rust</span>
   <i class="fa-solid fa-close"></i>
</div>
```

Note that the tag's name is one of the CSS classes on the `div`. This is to have different colors and formats for eact tag.

Then, the next function:

```typescript
...
/**
 * Removes duplicate tags from the tags container
 * @file $lib/utils/select.custom.ts
 * @param { HTMLDivElement } tagContainer - The div container that houses the tag.
 */
const reset = (tagContainer: HTMLDivElement): void => {
    tagContainer.querySelectorAll('.tag').forEach((tag) => {
        tag.parentElement?.removeChild(tag);
    });
};
```

To avoid having duplicate tags in the UI, this function was created. It ensures that a tag occurs once.

Then,

```typescript
/**
 * Update certain properties (value, placeholder, disabled, and focus) of the input element
 * @file $lib/utils/select.custom.ts
 * @param {HTMLInputElement} input - The input element
 * @param {number} numOfTagsRemaining - The remaining tags to accommodate
 */
const updateInput = (input: HTMLInputElement, numOfTagsRemaining: number): void => {
    if (numOfTagsRemaining === 0) {
        input.value = '';
        input.placeholder = `You can't add more tag...`;
        input.disabled = true;
    } else if (numOfTagsRemaining === 4) {
        input.placeholder = `Add up to ${numOfTagsRemaining} tags (atleast 1 is required)...`;
        input.focus();
    } else {
        input.value = '';
        input.placeholder = `You can add ${numOfTagsRemaining} more...`;
        input.disabled = false;
        input.focus();
    }
};
```

This function manipulates the input element where tags are added. It displays a different message depending on the number of tags you have included. Like dev.to's editor, you can only add up to 4 tags and when it's exhausted, the input field gets disabled.

Next,

```typescript
/**
 * Add tag to the list of tags
 * @file $lib/utils/select.custom.ts
 * @param {Array<string>} tags - Array of strings
 * @param {HTMLDivElement} tagContainer - The `div` element with `.tag-container` class to add tags to.
 */
const addTag = (tags: Array<string>, tagContainer: HTMLDivElement): void => {
    reset(tagContainer);
    tags
        .slice()
        .reverse()
        .forEach((tag) => {
            const input = createTag(tag);
            tagContainer.prepend(input);
        });
};
```

This function handles the proper addition of tags to the tag container. When a user types out the tag name and presses `Enter (return on MacOS)` or `,`, if the tag is available, this function adds such a tag to the UI. To ensure that tags maintain their position, `.slice().reverse()` was used. This method combines the previously defined functions to achieve this.

Next is:

```typescript
...
/**
 * Show tag suggestions and set input element's value
 * @file $lib/utils/select.custom.ts
 * @param {Array<Tag>} suggestions - Array of tags
 * @param {HTMLDivElement} suggestionsPannel - The `div` element with `.suggestions` class.
 * @param {inputElement} inputElement - The `input` element whose value will be altered.
 * @param {Array<string>} tags - The list of tags added to the UI
 * @param {HTMLDivElement} tagContainer - The container housing all tags.
 * @param {number} numOfTagsRemaining - The number of tags remaining.
 * @param {Array<string>} serverTagsArrayOfNames - The list of tags from the server.
 */
const showSuggestionPannel = (
    suggestions: Array<Tag>,
    suggestionsPannel: HTMLDivElement,
    inputElement: HTMLInputElement,
    tags: Array<string>,
    tagContainer: HTMLDivElement,
    numOfTagsRemaining: number,
    serverTagsArrayOfNames: Array<string>
): void => {
    if (suggestions.length > 0) {
        suggestionsPannel.innerHTML = '';
        const h5Element = document.createElement('h5');
        h5Element.innerHTML = `Available Tags`;
        h5Element.classList.add('headline', 'headline-3');
        suggestionsPannel.appendChild(h5Element);
        suggestions.forEach((suggested) => {
            const divElement = document.createElement('div');
            divElement.classList.add('suggestion-item');
            const spanElement = document.createElement('span');
            spanElement.classList.add('tag', suggested.name.toLowerCase());
            spanElement.innerHTML = suggested.name.toLowerCase();
            divElement.appendChild(spanElement);
            const smallElement = document.createElement('small');
            smallElement.innerHTML = suggested.description;
            divElement.appendChild(smallElement);
            suggestionsPannel.appendChild(divElement);
            divElement.addEventListener('click', () => {
                inputElement.value = suggested.name;
                // Add tag to the list of tags
                tags.push(suggested.name);
                performAddingRags(
                    tags,
                    tagContainer,
                    numOfTagsRemaining,
                    serverTagsArrayOfNames,
                    inputElement
                );
                suggestionsPannel.innerHTML = '';
            });
        });
    }
};
```

The panel that shows tags suggestions when you start typing is handled by this function. It also allows you to add tags to the array of tags by clicking on each of the available tags. Only tags from the server are available. That is, you can't just add any tag unless it's available from the backend or server (in the case of this project, I used a constant array of tags). Also, any previously selected tag will not be suggested and not be available to be added. I used the function `performAddingRags` to achieve the addition of tags. It has the following definition:

```typescript
...
/**
 * Add tag to the list of tags and perform other operations
 * @file $lib/utils/select.custom.ts
 * @param {Array<string>} tags - Array of strings
 * @param {HTMLDivElement} tagContainer - The `div` element with `.tag-container` class to add tags to.
 * @param {number} numOfTagsRemaining - The number of tags remaining
 * @param {Array<string>} serverTagsArrayOfNames - Array of tags from the DB
 * @param {HTMLInputElement} inputElement - The `input` element
 */
const performAddingRags = (
    tags: Array<string>,
    tagContainer: HTMLDivElement,
    numOfTagsRemaining: number,
    serverTagsArrayOfNames: Array<string>,
    inputElement: HTMLInputElement
): void => {
    // Include the tag in the list of tags in the UI
    addTag(tags, tagContainer);
    // Update the number of allowed tags
    numOfTagsRemaining = 4 - tags.length;
    // Remove the tag from serverTagsArrayOfNames
    serverTagsArrayOfNames = [
        ...serverTagsArrayOfNames.slice(0, serverTagsArrayOfNames.indexOf(tags[tags.length - 1])),
        ...serverTagsArrayOfNames.slice(serverTagsArrayOfNames.indexOf(tags[tags.length - 1]) + 1)
    ];
    // Update the properties of the input element
    updateInput(inputElement, numOfTagsRemaining);

    tagList.set(tags);
};
```

It uses the `addTag` function to do the actual adding in the UI, then it updates the number of tags permitted to be added. After that, it removes the just-added tag from the array of tags provided by the server. Then, input manipulation is done with the `updateInput` defined above. To ensure we keep track of the tags currently selected and to make them available even after the preview, we set the `tagList` store we defined at the beginning of this implementation.

Now, the function that combines all these functions is the `customSelect` function. Its content looks like this:

```typescript
...
/**
 * Manipulates the `DOM` with user tags and provides tags suggestions as user types.
 * @file $lib/utils/select.custom.ts
 * @param {Array<Tag>} serverTags - Tags from the server.
 * @param {HTMLDivElement} suggestionsPannel - The `div` element that shows suggestions.
 * @param {HTMLInputElement} input - The `input` element in which tags are entered.
 * @param {HTMLDivElement} tagContainer - The `div` housing selected tags.
 */
export const customSelect = (
    serverTags: Array<Tag>,
    suggestionsPannel: HTMLDivElement,
    input: HTMLInputElement,
    tagContainer: HTMLDivElement,
    tags: Array<string> = []
): void => {
    // Converts the Array<Tags> into Array<tag.name> for easy processing later on.
    let serverTagsArrayOfNames: Array<string> = serverTags.map((tag) => tag.name);

    // A reference tracking the number of tags left
    let numOfTagsRemaining = 0;

    // In case tags array isn't empty, particularly during preview of the post and update of articles, the tags are prepopulated in the UI.
    if (tags.length >= 1) {
        performAddingRags(tags, tagContainer, numOfTagsRemaining, serverTagsArrayOfNames, input);
    }

    // As user starts typing, do:
    input.addEventListener('keyup', (event) => {
        // Get a reference to the input element
        const inputElement = event.target as HTMLInputElement;

        // Filter the Array<Tags> and bring those tags whose `names`
        // match part or all the value of the input element
        const suggestions = serverTags.filter((tag) => {
            if (!tags.includes(tag.name)) {
                return tag.name.toLowerCase().match(input.value.toLowerCase());
            }
        });

        // Display suggestions based on the filter above
        // The input value might have been changed by this function too
        showSuggestionPannel(
            suggestions,
            suggestionsPannel,
            inputElement,
            tags,
            tagContainer,
            numOfTagsRemaining,
            serverTagsArrayOfNames
        );

        // Get the value of the input element and remove trailing or leading comma (,) since comma (,) adds a tag to the tag array and container
        const inputValue = inputElement.value
            .trim()
            .toLowerCase()
            .replace(/(^,)|(,$)/g, '');

        // When user presses the `Enter` key or comman (,)
        if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ',') {
            //  Check to ensure that the selected tag is available and has not been chosen before.
            if (serverTagsArrayOfNames.includes(inputValue) && !tags.includes(inputValue)) {
                // Add tag to the list of tags
                tags.push(inputValue);
                performAddingRags(
                    tags,
                    tagContainer,
                    numOfTagsRemaining,
                    serverTagsArrayOfNames,
                    inputElement
                );
            } else {
                // If the chosen tag isn't available, alert the user
                const span = document.createElement('span');
                span.classList.add('error');
                span.style.fontSize = '13px';
                span.innerHTML = `Sorry, you cannot add this tag either it's not available or been previously added.`;
                suggestionsPannel.appendChild(span);
            }
        }

        // Ensure that suggestion doesn't show up when input is empty
        if (input.value === '') {
            suggestionsPannel.innerHTML = '';
        }
    });

    // Listen to all clicks on the page's element and remove the selected tag.
    document.addEventListener('click', (event) => {
        const d = event.target as HTMLElement;
        // If the clicked element is an `i` tag with `fa-close` class, remove the tag from the UI and tags array and restore it to the array of tags from the server.
        // `<i class="fa-solid fa-close"></i>` is the fontawesome icon to remove a tag from the tag container and tags array.
        if (d.tagName === 'I' && d.classList.contains('fa-close')) {
            const tagName = d.previousElementSibling?.textContent?.trim().toLowerCase() as string;
            const index = tags.indexOf(tagName);
            tags = [...tags.slice(0, index), ...tags.slice(index + 1)];
            serverTagsArrayOfNames = [tagName, ...serverTagsArrayOfNames];
            addTag(tags, tagContainer);
            numOfTagsRemaining = 4 - tags.length;
            updateInput(input, numOfTagsRemaining);
        }
    });
};
```

The function was particularly commented on to explain why and how the logic was implemented.

That is it for the `select.custom.ts` file.

### Step 3: Add logic to parse markdown

In the `utils` subdirectory, we add another subfolder `editor` and then a file, `editor.utils.ts`. The content of the file is:

```typescript
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import hljs from 'highlight.js';

export const setCaretPosition = (
    ctrl: HTMLTextAreaElement | EventTarget,
    start: number,
    end: number
) => {
    const targetElement = ctrl as HTMLTextAreaElement;
    // Modern browsers
    if (targetElement.setSelectionRange) {
        targetElement.setSelectionRange(start, end);

        // IE8 and below
    } else {
        const range = document.createRange();
        range.collapse(true);
        range.setStart(targetElement, targetElement.selectionStart);
        range.setEnd(targetElement, targetElement.selectionEnd);
        range.selectNode(targetElement);
    }
};

export const getCaretPosition = (ctrl: HTMLTextAreaElement) =>
    ctrl.selectionStart
        ? {
                start: ctrl.selectionStart,
                end: ctrl.selectionEnd
            }
        : {
                start: 0,
                end: 0
            };

/**
 * Parses markdown to HTML using `marked` and `sanitizes` the HTML using `DOMPurify`.
 * @file $lib/utils/editor/editor.utils.ts
 * @param { string } text - The markdown text to be parsed
 * @returns {string} The parsed markdown
 */
export const parseMarkdown = (text: string): string => {
    marked.setOptions({
        renderer: new marked.Renderer(),
        highlight: function (code, lang) {
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        },
        langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class.
        pedantic: false,
        gfm: true,
        breaks: false,
        sanitize: false,
        smartypants: false,
        xhtml: false
    });

    return DOMPurify.sanitize(marked.parse(text));
};
```

`setCaretPosition` helps set the cursor in the `textarea`, though the JavaScript APIs used ain't totally supported by some browsers yet. In case you have a better way to implement `setCaretPosition` and `getCaretPosition`, kindly let me know in the comment section or make a pull request on this project's GitHub repository.

Next is `parseMarkdown` which just parses any markdown text to HTML. It uses `highlight.js` for syntax highlighting and `DOMPurify` to sanitize the parsed HTML.

### Step 4: Create `Editor.svelte` and `Preview.svelte` components

All we had done from step 2 till now, and parts of step 1, were framework agnostic. But now, we need to use svelte. Let's create a `components` subdirectory in the `lib` folder. In `components`, we create the `Editor` subfolder and in it, `Editor.svelte` and `Preview.svelte` should be created.

`Editor.svelte` has the following content:

```html
<script lang="ts">
    import { showPreview } from '$lib/stores/editor.store';
    import { notification } from '$lib/stores/notification.store';
    import type { EditorContent } from '$lib/types/article.interface';
    import { redColor, sadEmoji } from '$lib/utils/contants';
    import {
        getCaretPosition,
        parseMarkdown,
        setCaretPosition
    } from '$lib/utils/editor/editor.utils';
    import { onMount } from 'svelte';

    let contentTextArea: HTMLTextAreaElement;
    export let contentValue: string;
    export let markup: string;

    let updateTexareaValue: any, useKeyCombinations: any;
    onMount(() => {
        updateTexareaValue = (text: string) => {
            const { selectionEnd, selectionStart } = contentTextArea;
            contentValue = `${contentValue.slice(0, selectionEnd)}${text}${contentValue.slice(
                selectionEnd
            )}`;
            contentTextArea.focus({ preventScroll: false });
            setCaretPosition(contentTextArea, selectionStart, selectionStart + text.length / 2);
        };

        useKeyCombinations = (event: Event) => {
            let keysPressed: Record<string, boolean> = {};
            event.target?.addEventListener('keydown', (e) => {
                const keyEvent = e as KeyboardEvent;
                keysPressed[keyEvent.key] = true;

                if (
                    (keysPressed['Control'] || keysPressed['Meta'] || keysPressed['Shift']) &&
                    keyEvent.key == 'b'
                ) {
                    updateTexareaValue(`****`);
                } else if (
                    (keysPressed['Control'] || keysPressed['Meta'] || keysPressed['Shift']) &&
                    keyEvent.key == 'i'
                ) {
                    updateTexareaValue(`**`);
                } else if (
                    (keysPressed['Control'] || keysPressed['Meta'] || keysPressed['Shift']) &&
                    keyEvent.key === 'k'
                ) {
                    updateTexareaValue(`[text](link)`);
                    setCaretPosition(
                        contentTextArea,
                        getCaretPosition(contentTextArea).start,
                        getCaretPosition(contentTextArea).start + `[text](link)`.length / 2
                    );
                }
            });

            event.target?.addEventListener('keyup', (e) => {
                delete keysPressed[(e as KeyboardEvent).key];
            });
        };
    });
    const addBoldCommand = () => {
        updateTexareaValue(`****`);
    };
    const addItalicCommand = () => {
        updateTexareaValue(`**`);
    };
    const addLinkCommand = () => {
        updateTexareaValue(`[text](link)`);
    };
    const addUnorderedListCommand = () => {
        updateTexareaValue(`\n- First item\n- Second item\n`);
    };
    const addOrderedListCommand = () => {
        updateTexareaValue(`\n1. First item\n2. Second item\n`);
    };
    const addHeadingOneCommand = () => {
        updateTexareaValue(`\n# Your heading one {#id-name .class-name}\n\n`);
    };
    const addHeadingTwoCommand = () => {
        updateTexareaValue(`\n## Your heading one {#id-name .class-name}\n\n`);
    };
    const addHeadingThreeCommand = () => {
        updateTexareaValue(`\n### Your heading one {#id-name .class-name}\n\n`);
    };
    const addImageCommand = () => {
        updateTexareaValue(`![alt text](url)`);
    };
    const addCodeBlockCommand = () => {
        updateTexareaValue('\n```language\n<code here>\n```');
    };
    const addNoteCommand = () => {
        updateTexareaValue(
            '\n<div class="admonition note">\n<span class="title"><b>Note:</b> </span>\n<p></p>\n</div>'
        );
    };
    const addTipCommand = () => {
        updateTexareaValue(
            '\n<div class="admonition tip">\n<span class="title"><b>Tip:</b> </span>\n<p></p>\n</div>'
        );
    };
    const addWarningCommand = () => {
        updateTexareaValue(
            '\n<div class="admonition warning">\n<span class="title"><b>Warning:</b> </span>\n<p></p>\n</div>'
        );
    };
    const handlePreview = async (event: Event) => {
        const bodyEditor: EditorContent = {
            content: contentValue
        };

        markup = parseMarkdown(bodyEditor.content);
        if (markup.length >= 20) {
            $showPreview = !$showPreview;
        } else {
            (event.target as HTMLElement).title =
                'To preview, ensure your content is at least 19 characters.';

            $notification = {
                message: `To preview, ensure your content is at least 19 characters ${sadEmoji}...`,
                backgroundColor: `${redColor}`
            };
        }
    };
</script>

<div class="editor-icons">
    <div class="basic">
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p on:click={addBoldCommand} class="tooltip">
            <i class="fa-solid fa-bold" />
            <span class="tooltiptext">Bold command [Cmd/Ctrl(Shift) + B]</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addItalicCommand}>
            <i class="fa-solid fa-italic" />
            <span class="tooltiptext"> Italics command [Cmd/Ctrl(Shift) + I] </span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addLinkCommand}>
            <i class="fa-solid fa-link" />
            <span class="tooltiptext">Add link command [Cmd/Ctrl(Shift) + K]</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addUnorderedListCommand}>
            <i class="fa-solid fa-list" />
            <span class="tooltiptext">Add unordered list command</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addOrderedListCommand}>
            <i class="fa-solid fa-list-ol" />
            <span class="tooltiptext">Add ordered list command</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addHeadingOneCommand}>
            <i class="fa-solid fa-h" /><sub>1</sub>
            <span class="tooltiptext">Heading 1 command</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addHeadingTwoCommand}>
            <i class="fa-solid fa-h" /><sub>2</sub>
            <span class="tooltiptext">Heading 2 command</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addHeadingThreeCommand}>
            <i class="fa-solid fa-h" /><sub>3</sub>
            <span class="tooltiptext">Heading 3 command</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addImageCommand}>
            <i class="fa-solid fa-image" />
            <span class="tooltiptext">Add image command</span>
        </p>
    </div>
    <div class="others">
        <p class="dropdown">
            <i class="fa-solid fa-ellipsis-vertical dropbtn" />
            <span class="dropdown-content">
                <!-- svelte-ignore a11y-click-events-have-key-events -->
                <small on:click={addNoteCommand}>Add note</small>
                <!-- svelte-ignore a11y-click-events-have-key-events -->
                <small on:click={addTipCommand}>Add tip</small>
                <!-- svelte-ignore a11y-click-events-have-key-events -->
                <small on:click={addWarningCommand}>Add warning</small>
            </span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={addCodeBlockCommand}>
            <i class="fa-solid fa-code" />
            <span class="tooltiptext">Code block command</span>
        </p>
        <!-- svelte-ignore a11y-click-events-have-key-events -->
        <p class="tooltip" on:click={(e) => handlePreview(e)}>
            <i class="fa-solid fa-eye" />
        </p>
    </div>
</div>

<textarea
    bind:this={contentTextArea}
    bind:value={contentValue}
    on:focus="{(event) => {
        if (event.target) {
            useKeyCombinations(event);
        }
    }}"
    name="content"
    class="input-field"
    id="textAreaContent"
    placeholder="Write your article content here (markdown supported)..."
    data-input-field
    required
/>
```

The markup is straightforward. Just some HTML and CSS with some click events. In the `script` tag however, some logic abides. `useKeyCombinations` allows us to press keyboard combinations such as `CMD+B` (on MacOS) or `CTRL+B` (on Linux and Windows) to issue a bold command for markdown. For key combinations to work, we need the `keysPressed` object to keep track of the combinations pressed. The function still needs improvements, particularly for the Firefox browser on MacOS. Then there is the `updateTexareaValue` which does the real update of any command you select or press using keyboard key combinations. Though this function updates the textarea as expected and the point you want it to, the cursor manipulation is currently not working as expected. PRs or suggestions in the comment section are welcome. We also have  `handlePreview` function which calls the `parseMarkdown` function previously discussed and depending on the length of `parseMarkdown`'s output, shows the preview page. Other methods are just to add a markdown command to the `textarea` tag.

The `Preview.svelte` is quite simple:

```html
<script lang="ts">
    import hljs from 'highlight.js';
    import 'highlight.js/styles/night-owl.css';

    import { showPreview } from '$lib/stores/editor.store';
    import { onMount } from 'svelte';

    let articleContainer: HTMLDivElement;

    onMount(() => {
        hljs.highlightAll();
        const blocks = articleContainer.querySelectorAll('pre code.hljs');
        Array.prototype.forEach.call(blocks, function (block) {
            const language = block.result.language;
            const small = document.createElement('small');
            small.classList.add('language', language);
            small.innerText = language;
            block.appendChild(small);
        });
    });

    export let markup: string;
</script>

<section class="section feature" aria-label="feature">
    <div class="container">
        <div class="preview full-text">
            <div class="main-text" bind:this={articleContainer}>
                <p>{@html markup}</p>
            </div>
            <!-- svelte-ignore a11y-click-events-have-key-events -->
            <div
                title="Continue editing"
                class="side-text"
                on:click={() => {
                    $showPreview = !showPreview;
                }}
            >
                <i class="fa-solid fa-pen-to-square" />
            </div>
        </div>
    </div>
</section>

<style>
    .preview.full-text {
        grid-template-columns: 0.2fr 3.8fr;
    }

    .side-text i {
        cursor: pointer;
    }
    @media (min-width: 992px) {
        .preview.full-text .side-text {
            background: #011627;
            position: fixed;
        }
    }
</style>
```

It renders the output of the parsed markdown, in this line `<p>{@html markup}</p>` and uses `highlight.js` to highlight syntax. Since `highlight.js` doesn't show codeblock's language by default, I modified its presentation by showing the language of the codeblock at the top right corner of the `pre` tag. I consulted [this GitHub comment](https://github.com/highlightjs/highlight.js/issues/1108#issuecomment-195080618) to achieve that. I also opted for the [Night Owl theme](https://highlightjs.org/static/demo/#Night%20Owl). There was also an icon which when clicked, returns you to the editor page.

### Step 5: Connect `Editor.svelte` and `Preview.svelte` together

It's time to bring together all we had written since the start of this implementation. Let's open up `routes/+page.svelte` and populate it with:

```html
<script lang="ts">
    import { showPreview } from '$lib/stores/editor.store';
    import { scale } from 'svelte/transition';
    import { flip } from 'svelte/animate';
    import type { CustomError } from '$lib/types/error.interface';
    import { afterUpdate, onMount } from 'svelte';
    import { customSelect } from '$lib/utils/custom/select.custom';
    import { tagsFromServer } from '$lib/utils/contants';
    import Editor from '$lib/components/Editor/Editor.svelte';
    import Preview from '$lib/components/Editor/Preview.svelte';
    import { tagList } from '$lib/stores/tag.store';

    let contentValue = '',
        titleValue = '',
        imageValue: string | Blob,
        spanElement: HTMLSpanElement,
        italicsElement: HTMLElement,
        foregroundImageLabel: HTMLLabelElement,
        markup = '',
        errors: Array<CustomError> = [],
        tagContainer: HTMLDivElement,
        suggestionPannel: HTMLDivElement,
        tagInput: HTMLInputElement,
        isPublished: boolean;

    onMount(() => {
        if ($tagList.length >= 1) {
            customSelect(tagsFromServer, suggestionPannel, tagInput, tagContainer, $tagList);
        } else {
            customSelect(tagsFromServer, suggestionPannel, tagInput, tagContainer);
        }
    });
    afterUpdate(() => {
        if (tagInput || suggestionPannel || tagContainer) {
            if ($tagList.length >= 1) {
                customSelect(tagsFromServer, suggestionPannel, tagInput, tagContainer, $tagList);
            } else {
                customSelect(tagsFromServer, suggestionPannel, tagInput, tagContainer);
            }
        }
    });

    const onFileSelected = (e: Event) => {
        const target = e.target as HTMLInputElement;
        if (target && target.files) {
            imageValue = target.files[0];
            if (imageValue) {
                spanElement.innerHTML = imageValue.name;
                let reader = new FileReader();
                reader.readAsDataURL(imageValue);
                reader.onload = (e) => {
                    const imgElement = document.createElement('img');
                    imgElement.src = e.target?.result as string;
                    imgElement.classList.add('img-cover');
                    imgElement.width = 1602;
                    imgElement.height = 903;
                    foregroundImageLabel.appendChild(imgElement);
                };
            }
        }
    };
</script>

<svelte:head>
    {#if $showPreview}
        <title>Article preview | JohnSpeaks</title>
    {:else}
        <title>Write an article | JohnSpeaks</title>
    {/if}
</svelte:head>

<section class="section feature" aria-label="feature">
    <div class="container">
        <h2 class="headline headline-2 section-title center">
            {#if $showPreview}
                <span class="span">Article preview</span>
            {:else}
                <span class="span">Write an article</span>
            {/if}
        </h2>

        <div class="card-wrapper">
            {#if $showPreview}
                <Preview {markup} />
            {:else}
                <form class="form" data-form enctype="multipart/form-data">
                    {#if errors}
                        {#each errors as error (error.id)}
                            <p
                                class="center error"
                                transition:scale|local={{ start: 0.7 }}
                                animate:flip={{ duration: 200 }}
                            >
                                {error.error}
                            </p>
                        {/each}
                    {/if}
                    <label for="file-input" bind:this={foregroundImageLabel}>
                        <span bind:this={spanElement}>Add Cover Image</span>
                        <i class="fa-solid fa-2x fa-camera" bind:this={italicsElement} />
                        <input
                            id="file-input"
                            type="file"
                            on:change={(e) => onFileSelected(e)}
                            name="fore-ground"
                            class="input-field"
                            accept="images/*"
                            placeholder="Add a cover image"
                            required
                            data-input-field
                        />
                    </label>

                    <input
                        type="text"
                        name="title"
                        bind:value={titleValue}
                        class="input-field"
                        placeholder="New article title here..."
                        maxlength="250"
                        required
                        data-input-field
                    />

                    <div class="tags-and-suggestion-container">
                        <div class="tag-container" bind:this={tagContainer}>
                            <input
                                bind:this={tagInput}
                                type="text"
                                id="tag-input"
                                placeholder="Add up to 4 tags (atleast 1 is required)..."
                            />
                        </div>
                        <div class="suggestions" bind:this={suggestionPannel} />
                    </div>

                    <Editor bind:markup bind:contentValue />

                    <div class="input-wrapper">
                        <input type="checkbox" id="is-published" bind:checked={isPublished} />
                        <label for="is-published">Publish</label>
                    </div>

                    <button
                        class="btn btn-primary center"
                        type="submit"
                        title="Create article. Ensure you fill all the fields in this form."
                    >
                        <span class="span">Create article</span>

                        <i class="fa-solid fa-file-pen" />
                    </button>
                </form>
            {/if}
        </div>
    </div>
</section>

<style>
    .btn {
        margin-top: 1rem;
        margin-left: auto;
        margin-right: auto;
    }
    .input-wrapper {
        display: flex;
        gap: 0;
        align-items: center;
        justify-content: center;
    }
    .input-wrapper input {
        width: 3rem;
    }
</style>
```

We used the `customSelect` previously discussed in the `onMount` and `afterUpdate` blocks to ensure that DOM is ready before any manipulation of it can start and ensure that after preview, all functionalities are still intact respectively. `onFileSelected` performs some basic operations when a user uploads an image. You will notice that `bind:this` is extensively used. This is to prevent using something like `document.getElementById()` which is not recommended in the svelte ecosystem. `Editor.svelte` and `Preview.svelte` are also rendered based on the value of `showPreview` store. We can dynamically render these components as well using the `await` syntax:

```html
{#await import('$lib/components/Editor/Preview.svelte') then Preview}
    <Preview.default {markup} />
{/await}
```

### Step 6: Define the entire application's layout

To wrap up, let's have our project's layout defined. Make `routes/+layout.svelte` looks like this:

```html
<script lang="ts">
    import Footer from '$lib/components/Footer/Footer.svelte';
    import '$lib/dist/css/all.min.css';
    import '$lib/dist/css/style.min.css';
    import { notification } from '$lib/stores/notification.store';
    import { afterUpdate } from 'svelte';
    import { fly } from 'svelte/transition';

    let noficationElement: HTMLParagraphElement;

    afterUpdate(async () => {
        if (noficationElement && $notification.message !== '') {
            setTimeout(() => {
                noficationElement.classList.add('disappear');
                $notification = { message: '', backgroundColor: '' };
            }, 5000);
        }
    });
</script>

{#if $notification.message && $notification.backgroundColor}
    <p
        class="notification"
        bind:this={noficationElement}
        style="background: {$notification.backgroundColor}"
        in:fly={{ x: 200, duration: 500, delay: 500 }}
        out:fly={{ x: 200, duration: 500 }}
    >
        {$notification.message}
    </p>
{/if}

<main>
    <article>
        <slot />
    </article>
</main>

<Footer />
```

We imported the CSS files needed and also show the content of the `notification` store for 5 seconds (`5000ms`). We only show notifications if there's a new message. Then we use the `slot` tag which is mandatory to render child components. There was also the `Footer` component which is in `src/lib/components/Footer/Footer.svelte` and its content is just:

```html
<footer>
    <div class="container">
        <div class="footer-bottom">
            <p class="copyright">
                &copy; Programmed by <a href="https://github.com/Sirneij" class="copyright-link">
                    John O. Idogun.
                </a>
            </p>

            <ul class="social-list">
                <li>
                    <a href="https://twitter.com/Sirneij" class="social-link">
                        <i class="fa-brands fa-twitter" />

                        <span class="span">Twitter</span>
                    </a>
                </li>

                <li>
                    <a href="https://www.linkedin.com/in/idogun-john-nelson/" class="social-link">
                        <i class="fa-brands fa-linkedin" />

                        <span class="span">LinkedIn</span>
                    </a>
                </li>

                <li>
                    <a href="https://dev.to/sirneij/" class="social-link">
                        <i class="fa-brands fa-dev" />

                        <span class="span">Dev.to</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>
</footer>
```

With this, we are done! Ensure you take a look at the complete code on GitHub.

## Outro

Enjoyed this article? Consider [contacting me for a job, something worthwhile or buying a coffee ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/). It isn't bad if you help share this article for wider coverage. I will appreciate it...