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:
- HTML
- CSS
- TypeScript
- SvelteKit (v1.5.0)
- Fontawesome (v6.2.0) for icons
- Highlight.js (v11.7.0) for syntax highlighting.
- Marked.js (v4.2.1) for parsing markdown to HTML.
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:
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
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
Building
To create a production version of your app:
npm run build
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([]);
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;
};
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>
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);
});
};
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();
}
};
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);
});
};
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 = '';
});
});
}
};
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);
};
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);
}
});
};
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));
};
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
/>
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>
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>
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}
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 />
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">
© 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? 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!
Top comments (5)
wow,what a creative post
Thank you!!!!
Wow! A really enticing project. Thanks!
Thank you!
Could you please make a video tutorial?