Building a clone of dev.to's markdown editor with SvelteKit and TypeScript

sirneij

John Owolabi Idogun

Posted on February 14, 2023

Building a clone of dev.to's markdown editor with SvelteKit and TypeScript

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 experiment with it. However, I became resolute to pick up the language for backend development and subsequently, frontend development using WebAssembly. Since I learned by doing, I picked up the challenge to build a 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 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:

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 logo Sirneij / devto-editor-clone

Fully functional clone of dev.to's post creation and/or update form(s) written in SvelteKit and TypeScript

create-svelte

Everything you need to build a Svelte project, powered by create-svelte.

Creating a project

If you're seeing this, you've probably already done this step. Congrats!

# create a new project in the current directory
npm create svelte@latest

# create a new project in my-app
npm create svelte@latest my-app
Enter fullscreen mode Exit fullscreen mode

Developing

Once you've created a project and installed dependencies with npm install (or pnpm install or yarn), start a development server:

npm run dev

# or start the server and open the app in a new browser tab
npm run dev -- --open
Enter fullscreen mode Exit fullscreen mode

Building

To create a production version of your app:

npm run build
Enter fullscreen mode Exit fullscreen mode

You can preview the production build with npm run preview.

To deploy your app, you may need to install an adapter for your target environment.




The finished project was deployed on vercel and can be accessed here (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:

// 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([]);
Enter fullscreen mode Exit fullscreen mode

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:

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;
};
Enter fullscreen mode Exit fullscreen mode

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:

<div class="tag rust">
   <span>rust</span>
   <i class="fa-solid fa-close"></i>
</div>
Enter fullscreen mode Exit fullscreen mode

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

Then, the next function:

...
/**
 * 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);
    });
};
Enter fullscreen mode Exit fullscreen mode

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

Then,

/**
 * 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();
    }
};
Enter fullscreen mode Exit fullscreen mode

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,

/**
 * 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);
        });
};
Enter fullscreen mode Exit fullscreen mode

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:

...
/**
 * 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 = '';
            });
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

The panel that shows tag 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:

...
/**
 * 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);
};
Enter fullscreen mode Exit fullscreen mode

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:

...
/**
 * 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);
        }
    });
};
Enter fullscreen mode Exit fullscreen mode

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:

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));
};
Enter fullscreen mode Exit fullscreen mode

setCaretPosition helps set the cursor in the textarea, though the JavaScript APIs used ain't 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:

<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
/>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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 the code block's language by default, I modified its presentation by showing the language of the code block at the top right corner of the pre tag. I consulted this GitHub comment to achieve that. I also opted for the Night Owl theme. 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 have written since the start of this implementation. Let's open up routes/+page.svelte and populate it with:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

{#await import('$lib/components/Editor/Preview.svelte') then Preview}
    <Preview.default {markup} />
{/await}
Enter fullscreen mode Exit fullscreen mode

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:

<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 />
Enter fullscreen mode Exit fullscreen mode

We imported the CSS files needed and also showed 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:

<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>
Enter fullscreen mode Exit fullscreen mode

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

Outro

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and Twitter.

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

💖 💪 🙅 🚩
sirneij
John Owolabi Idogun

Posted on February 14, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related