Building a clone of dev.to's markdown editor with SvelteKit and TypeScript
John Owolabi Idogun
Posted on February 14, 2023
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.
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:
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';importtype{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
*/constcreateTag=(tagName:string):HTMLDivElement=>{constdiv=document.createElement('div');div.classList.add('tag',tagName.trim().toLowerCase());constspan=document.createElement('span');span.innerHTML=tagName.trim().toLowerCase();constremoveTag=document.createElement('i');removeTag.classList.add('fa-solid','fa-close');div.appendChild(span);div.appendChild(removeTag);returndiv;};
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:
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.
*/constreset=(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
*/constupdateInput=(input:HTMLInputElement,numOfTagsRemaining:number):void=>{if (numOfTagsRemaining===0){input.value='';input.placeholder=`You can't add more tag...`;input.disabled=true;}elseif (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.
*/constaddTag=(tags:Array<string>,tagContainer:HTMLDivElement):void=>{reset(tagContainer);tags.slice().reverse().forEach((tag)=>{constinput=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.
*/constshowSuggestionPannel=(suggestions:Array<Tag>,suggestionsPannel:HTMLDivElement,inputElement:HTMLInputElement,tags:Array<string>,tagContainer:HTMLDivElement,numOfTagsRemaining:number,serverTagsArrayOfNames:Array<string>):void=>{if (suggestions.length>0){suggestionsPannel.innerHTML='';consth5Element=document.createElement('h5');h5Element.innerHTML=`Available Tags`;h5Element.classList.add('headline','headline-3');suggestionsPannel.appendChild(h5Element);suggestions.forEach((suggested)=>{constdivElement=document.createElement('div');divElement.classList.add('suggestion-item');constspanElement=document.createElement('span');spanElement.classList.add('tag',suggested.name.toLowerCase());spanElement.innerHTML=suggested.name.toLowerCase();divElement.appendChild(spanElement);constsmallElement=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 tagstags.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
*/constperformAddingRags=(tags:Array<string>,tagContainer:HTMLDivElement,numOfTagsRemaining:number,serverTagsArrayOfNames:Array<string>,inputElement:HTMLInputElement):void=>{// Include the tag in the list of tags in the UIaddTag(tags,tagContainer);// Update the number of allowed tagsnumOfTagsRemaining=4-tags.length;// Remove the tag from serverTagsArrayOfNamesserverTagsArrayOfNames=[...serverTagsArrayOfNames.slice(0,serverTagsArrayOfNames.indexOf(tags[tags.length-1])),...serverTagsArrayOfNames.slice(serverTagsArrayOfNames.indexOf(tags[tags.length-1])+1)];// Update the properties of the input elementupdateInput(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.
*/exportconstcustomSelect=(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.letserverTagsArrayOfNames:Array<string>=serverTags.map((tag)=>tag.name);// A reference tracking the number of tags leftletnumOfTagsRemaining=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 elementconstinputElement=event.targetasHTMLInputElement;// Filter the Array<Tags> and bring those tags whose `names`// match part or all the value of the input elementconstsuggestions=serverTags.filter((tag)=>{if (!tags.includes(tag.name)){returntag.name.toLowerCase().match(input.value.toLowerCase());}});// Display suggestions based on the filter above// The input value might have been changed by this function tooshowSuggestionPannel(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 containerconstinputValue=inputElement.value.trim().toLowerCase().replace(/(^,)|(,$)/g,'');// When user presses the `Enter` key or comman (,)if ((eventasKeyboardEvent).key==='Enter'||(eventasKeyboardEvent).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 tagstags.push(inputValue);performAddingRags(tags,tagContainer,numOfTagsRemaining,serverTagsArrayOfNames,inputElement);}else{// If the chosen tag isn't available, alert the userconstspan=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 emptyif (input.value===''){suggestionsPannel.innerHTML='';}});// Listen to all clicks on the page's element and remove the selected tag.document.addEventListener('click',(event)=>{constd=event.targetasHTMLElement;// 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')){consttagName=d.previousElementSibling?.textContent?.trim().toLowerCase()asstring;constindex=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:
importDOMPurifyfrom'dompurify';import{marked}from'marked';importhljsfrom'highlight.js';exportconstsetCaretPosition=(ctrl:HTMLTextAreaElement|EventTarget,start:number,end:number)=>{consttargetElement=ctrlasHTMLTextAreaElement;// Modern browsersif (targetElement.setSelectionRange){targetElement.setSelectionRange(start,end);// IE8 and below}else{constrange=document.createRange();range.collapse(true);range.setStart(targetElement,targetElement.selectionStart);range.setEnd(targetElement,targetElement.selectionEnd);range.selectNode(targetElement);}};exportconstgetCaretPosition=(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
*/exportconstparseMarkdown=(text:string):string=>{marked.setOptions({renderer:newmarked.Renderer(),highlight:function (code,lang){constlanguage=hljs.getLanguage(lang)?lang:'plaintext';returnhljs.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});returnDOMPurify.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';importtype{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';letcontentTextArea:HTMLTextAreaElement;exportletcontentValue:string;exportletmarkup:string;letupdateTexareaValue: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)=>{letkeysPressed:Record<string,boolean>={};event.target?.addEventListener('keydown',(e)=>{constkeyEvent=easKeyboardEvent;keysPressed[keyEvent.key]=true;if ((keysPressed['Control']||keysPressed['Meta']||keysPressed['Shift'])&&keyEvent.key=='b'){updateTexareaValue(`****`);}elseif ((keysPressed['Control']||keysPressed['Meta']||keysPressed['Shift'])&&keyEvent.key=='i'){updateTexareaValue(`**`);}elseif ((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)=>{deletekeysPressed[(easKeyboardEvent).key];});};});constaddBoldCommand=()=>{updateTexareaValue(`****`);};constaddItalicCommand=()=>{updateTexareaValue(`**`);};constaddLinkCommand=()=>{updateTexareaValue(`[text](link)`);};constaddUnorderedListCommand=()=>{updateTexareaValue(`\n- First item\n- Second item\n`);};constaddOrderedListCommand=()=>{updateTexareaValue(`\n1. First item\n2. Second item\n`);};constaddHeadingOneCommand=()=>{updateTexareaValue(`\n# Your heading one {#id-name .class-name}\n\n`);};constaddHeadingTwoCommand=()=>{updateTexareaValue(`\n## Your heading one {#id-name .class-name}\n\n`);};constaddHeadingThreeCommand=()=>{updateTexareaValue(`\n### Your heading one {#id-name .class-name}\n\n`);};constaddImageCommand=()=>{updateTexareaValue(`![alt text](url)`);};constaddCodeBlockCommand=()=>{updateTexareaValue('\n```
language\n<code here>\n
```');};constaddNoteCommand=()=>{updateTexareaValue('\n<div class="admonition note">\n<span class="title"><b>Note:</b> </span>\n<p></p>\n</div>');};constaddTipCommand=()=>{updateTexareaValue('\n<div class="admonition tip">\n<span class="title"><b>Tip:</b> </span>\n<p></p>\n</div>');};constaddWarningCommand=()=>{updateTexareaValue('\n<div class="admonition warning">\n<span class="title"><b>Warning:</b> </span>\n<p></p>\n</div>');};consthandlePreview=async (event:Event)=>{constbodyEditor:EditorContent={content:contentValue};markup=parseMarkdown(bodyEditor.content);if (markup.length>=20){$showPreview=!$showPreview;}else{(event.targetasHTMLElement).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><divclass="editor-icons"><divclass="basic"><!-- svelte-ignore a11y-click-events-have-key-events --><pon:click={addBoldCommand}class="tooltip"><iclass="fa-solid fa-bold"/><spanclass="tooltiptext">Bold command [Cmd/Ctrl(Shift) + B]</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addItalicCommand}><iclass="fa-solid fa-italic"/><spanclass="tooltiptext"> Italics command [Cmd/Ctrl(Shift) + I] </span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addLinkCommand}><iclass="fa-solid fa-link"/><spanclass="tooltiptext">Add link command [Cmd/Ctrl(Shift) + K]</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addUnorderedListCommand}><iclass="fa-solid fa-list"/><spanclass="tooltiptext">Add unordered list command</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addOrderedListCommand}><iclass="fa-solid fa-list-ol"/><spanclass="tooltiptext">Add ordered list command</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addHeadingOneCommand}><iclass="fa-solid fa-h"/><sub>1</sub><spanclass="tooltiptext">Heading 1 command</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addHeadingTwoCommand}><iclass="fa-solid fa-h"/><sub>2</sub><spanclass="tooltiptext">Heading 2 command</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addHeadingThreeCommand}><iclass="fa-solid fa-h"/><sub>3</sub><spanclass="tooltiptext">Heading 3 command</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addImageCommand}><iclass="fa-solid fa-image"/><spanclass="tooltiptext">Add image command</span></p></div><divclass="others"><pclass="dropdown"><iclass="fa-solid fa-ellipsis-vertical dropbtn"/><spanclass="dropdown-content"><!-- svelte-ignore a11y-click-events-have-key-events --><smallon:click={addNoteCommand}>Add note</small><!-- svelte-ignore a11y-click-events-have-key-events --><smallon:click={addTipCommand}>Add tip</small><!-- svelte-ignore a11y-click-events-have-key-events --><smallon:click={addWarningCommand}>Add warning</small></span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={addCodeBlockCommand}><iclass="fa-solid fa-code"/><spanclass="tooltiptext">Code block command</span></p><!-- svelte-ignore a11y-click-events-have-key-events --><pclass="tooltip"on:click={(e)=> handlePreview(e)}>
<iclass="fa-solid fa-eye"/></p></div></div><textareabind: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-fieldrequired/>
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.
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';importtype{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';importEditorfrom'$lib/components/Editor/Editor.svelte';importPreviewfrom'$lib/components/Editor/Preview.svelte';import{tagList}from'$lib/stores/tag.store';letcontentValue='',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);}}});constonFileSelected=(e:Event)=>{consttarget=e.targetasHTMLInputElement;if (target&&target.files){imageValue=target.files[0];if (imageValue){spanElement.innerHTML=imageValue.name;letreader=newFileReader();reader.readAsDataURL(imageValue);reader.onload=(e)=>{constimgElement=document.createElement('img');imgElement.src=e.target?.resultasstring;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><sectionclass="section feature"aria-label="feature"><divclass="container"><h2class="headline headline-2 section-title center">
{#if $showPreview}
<spanclass="span">Article preview</span>
{:else}
<spanclass="span">Write an article</span>
{/if}
</h2><divclass="card-wrapper">
{#if $showPreview}
<Preview{markup}/>
{:else}
<formclass="form"data-formenctype="multipart/form-data">
{#if errors}
{#each errors as error (error.id)}
<pclass="center error"transition:scale|local={{start:0.7}}animate:flip={{duration:200}}>
{error.error}
</p>
{/each}
{/if}
<labelfor="file-input"bind:this={foregroundImageLabel}><spanbind:this={spanElement}>Add Cover Image</span><iclass="fa-solid fa-2x fa-camera"bind:this={italicsElement}/><inputid="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><inputtype="text"name="title"bind:value={titleValue}class="input-field"placeholder="New article title here..."maxlength="250"requireddata-input-field/><divclass="tags-and-suggestion-container"><divclass="tag-container"bind:this={tagContainer}><inputbind:this={tagInput}type="text"id="tag-input"placeholder="Add up to 4 tags (atleast 1 is required)..."/></div><divclass="suggestions"bind:this={suggestionPannel}/></div><Editorbind:markupbind:contentValue/><divclass="input-wrapper"><inputtype="checkbox"id="is-published"bind:checked={isPublished}/><labelfor="is-published">Publish</label></div><buttonclass="btn btn-primary center"type="submit"title="Create article. Ensure you fill all the fields in this form."><spanclass="span">Create article</span><iclass="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-wrapperinput{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:
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:
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!