Markdown Editor
Tools
- react
- typescript
- marked
- styled-components
- cypress
- redux
- redux-toolkit
Introduction
Project requirements and design provided thanks to FrontEndMentors.
Before jumping into frameworks, I wanted to take the time to understand the fundamentals of web development. So a year in, I was capable of building simple applications with HTML, CSS, and JavaScript. At that point I figured it was time to start learning React and developing applications with the framework. This led me to take on this FrontendMentor challenge which I figured would provide a good challenge for me newly learned React skills.
The requirement of the project is to create a single-page markdown editor application that is capable of the following:
- Preview markdown syntax as HTML while editing
- Save markdown documents for later editing
- Switch between saved markdown documents
- Delete and edit markdown documents
Techstack Used
This Project was bootstrapped with Vite using the the React/Typescript template. The project is linted with ESLint using react, prettier, cypress, and the jsx-a11y plugins. The main techstack used include:
- React, a JavaScript library for building user interfaces
- Cypress, a JavaScript testing library for creating end-to-end and unit tests
- Styled Components, a CSS-in-JS tool for styling React components
- Redux and Redux toolkit, a state management tool for JavaScript applications
- marked, a low-level compiler for parsing markdown without caching or blocking for long periods of time
The Local Storage Browser API is used to save markdown documents to the user's browser
Development
This section discusses the development process of the application, and the challenges that were faced.
Rendering Markdown
The first task I wanted to complete was converting the user input string into markdown on the page. It would take an extenstive amount of time to create my own implementation, so it was decided early on in development to implement a library that would handle this.
After some research, I came upon the marked
library which was easy to configure and provided fast results. It takes in a string, and returns valid markdown syntax back as valid HTML string.
One issue with the package is that it does not sanitize the output HTML, meaning that attackers can inject potentially harmful code/syntax into the application. To remedy this, the DOMPurify package was installed which sanitizes HTML string. The implementation of this library can be found in the Preview Markdown sub section.
Redux
When I initally planned the application and got the markdown render working, I came to the realization that I was creating many instances of state, and more importantly, was passing this state as props down through multiple components. This method of passing props down through multiple components is known as prop drilling Some examples of this include:
- The left side-bar having to take in documents to display and switch over to another document.
- The top-bar needing document information to save documents and delete documents
Each markdown document is set to include the following information
- The markdown contnet
- The document name
- The document status
- The original name of the document
Too many props were being passed through, making the codebase hard to read and page renders inefficient.
After a bit of research, I came to the conclusion that a state management tool will be needed for this application. Redux along with Redux Toolkit was chosen to manage the state of the application. Redux is the state management system itself, while Redux Toolkit contians method to work with the store in a simply way. This would be my first time using a state management tool, so before starting, I took the time to read through the documentation to get familiar with it.
Implementing Redux
As I planned out the state management structure, I came to the following conclusion: the redux slice will hold information about the current document that is on the page. This means that the left side-bar and top-bar components will no longer need to take in props, and instead can extract the document information from a central location, the Redux store.
One requirement of the of the application was to make it clear to the user when an "event" occured, these events include:
- Deleting a document
- Saving a document
- Changing the document title
- Overwriting a document
If any of these events occured, a modal would appear giving the user the ability to confirm the action, or cancel and leave the document in its current state. Again, instead of passing down props into the modal, the component itself can get the updated information from the store.
The implemenetation of the Redux slice can be found in the document-reducer.ts file. The reducer slice contains the following actions:
setMarkdownInformation
, takes in an object containing the entire markdown document information and sets it in the storesetMarkdownContent
, when a user types into the markdown text area, the slice's documentMarkdown property is updated.setCurrentMarkdownTitle
, change the title in the redux store (local storage change done somewhere else)setNullDocument
, a user can delete a document and potentially be left with no document to view, thus the store should have a "null" state to indicate there is no document on the page to edit.show/hideModal
, since the store contains properties for modal informatino,show
will take in arguments to render within the modal.hide
removes all modal data. The existence of modal data will render theModal
component within the application.
Redux Dispatch Functions
Instead of calling the dispatch function in the components and passing down the payload information directly, functions that return another function that call the store's dispatch with the arguments provided were created. I did this because it was easier to understand what dispatch was doing based on the name of the function, and it made the components easier to read.
Still within document-reducer.ts
, lets review the simple function below:
export const updateCurrentDocumentTitle = (title: string) => {
return (dispatch: AppDispatch) => {
dispatch(setCurrentMarkdownTitle(title));
};
};
The function updateCurrentDocumentTitle
takes in a string and returns a function that calls the reducer dispatch function, in this case it calls the setCurrentMarkdownTitle
action. How dispatch is called in the app will be explained later, but with this implementation, we have a function with a clear intent on what it does, and is responsible for a single update to the store. It may not seem like much, but to me this makes it easier to understand the intent of actions and organize our code-base as well.
Initializing the Store
With the reducer created and actions ready, all that is left is to initialize the store in the application. In the redux-hooks.ts
file, two important hooks were created that allow components to interact with the store:
useAppDispatch
, returns a type-friendly dispatch function to fire actions to the store.useAppSelector
, a hook that extracts the store properties and returns it to the component component.
This implementation is the redux-toolkit recommended practice, store.ts
contains the defined types of the store which are used for the hooks.
When the reducer slice is initialized, the inital state is set to the following:
const initialState: DocumentContext = {
document: {
originalDocumentTitle: 'welcome.md',
currentDocumentTitle: 'welcome.md',
documentMarkdown: welcomeMarkdownText,
isNewDocument: true,
},
modalAction: null,
modalInformation: null,
targetSwitch: null,
};
Initially I used a useEffect
hook in App.tsx
to call the dispatch to render the welcome.md
markdown. However, since every page load is meant to render the welcome markdown anyway, it was best to just have it be the default state/data of the reducer.
Redux Actions
Originally, the application had functions within related components that used the selector
and dispatch
method to retrieve data and modify the store. However, this implementation was lacking in multiple ways:
- Making component files larger and harder to read.
- Making it harder to debug.
I wasn't a fan of coming back to revisit a component and seeing complex logic within the component itself, there should be a better way for the component to call the needed action without over-bloating. The solution to this was a simple one, put the related functionaility into a React Hook.
Within the document-action.ts file, the useDocumentAction
hook contains multiple functions that modify the document store in various ways. This hook uses the useAppSelector
and useAppDispatch
hooks to retrieve the store information and dispatch method. This implementation makes it so that not every component needs to call the reducer hooks directly, instead, this hook acts as a proxy between the components and the actions needed to interact with the store.
For example, see the snippet for the newDoc
function:
const newDoc = useCallback(() => {
if (!document) {
dispatch(setNewDocument());
return;
} else if (document.isNewDocument)
dispatch(
displayModal(
`do you want to discard the document ${document.currentDocumentTitle}? This cannot be reversed.`,
'Discard new document',
'discard'
)
);
else
dispatch(
displayModal(
`do you want to discard any changes made to ${
document?.currentDocumentTitle || ''
}? This cannot be reversed.`,
'Discard changes',
'discard'
)
);
}, [document]);
The function is wrapped around the useCallback
react hook, which ensures that the function is only recreated when document
property is modified. When called, it sets a new document if the document
is not set (meaning there is no document rendered on the page), else it will use a dispatch method to render a modal that asks the user if they want to discard a new or saved document.
Other functions exist within this hook as well, all of them which were originally within components but moved here. Moving all these functions into the hook doesn't improve performance, but having just one location where they can exist makes it easier to organize and extract.
This same implementation was used for actions that result from a user's choice on the modal. The same logic applies here as well, have one hook that contains the store actions, and return functions that modify the store. This file can be found within the Modal
feature folder in the modal-aciton.ts file.
Application Logic and Structure
This sub-section will delve into the logic of the actions created from the redux slice, along with their use within the application. Though I already explained their purpose, providing more context to their use will paint a clearer picture of what I was attempting.
Saving a Document
When a user saves a document, the application must ensure the following:
- The document title is valid
- The document title does not already exist
In document-action.ts
, the saveDoc function is responsible for validating document titles, checking the new document status, and confirming if a document needs to be overwritten. When the save button is clicked within the TopBar.tsx component the saveDoc
function executes the following:
-
If there is no current document (users can go into the local storage and erase the data), then the function returns.
-
The current document title is verified using a regex, if invalid, then the
displayModal
function is called with the "title" argument, which is passed into the dispatch function to display the invalid title modal to the user. The function then returns. -
Next the function checks if there are any documents in the local storge, if none exist, then it is safe to assume that there is no document with the same title as the current one, so the markdown information is stringified and set as the local storage data, a date property is also inserted into the object to keep track of when the document was last saved (more on this later).
- After this, the dispatch function is passed the result of
saveDocumentInformation
which will update the redux store with the current document information, which sets the original title to the current title, and sets thenewDocument
property to false. Finally, thewindow
object fires a storage event, which will ensure that the left-side bar will populate the document list with the latest saved document infomration.
- After this, the dispatch function is passed the result of
-
If documents exist in local storage and the current document is new, then we need check if the current document title already exists in the local storage. If it does, then the
displayModal
function is called with the "overwrite" argument, which is passed into the dispatch function to display the overwrite modal to the user.- If the title does not exist, then the current document is added to the local storage data, and the dispatch function is passed the result of
saveDocumentInformation
which will update the redux store with the current document information. Like before, thestorage
event is fired to ensure that our document list is up to date with what we just inserted into local storage.
- If the title does not exist, then the current document is added to the local storage data, and the dispatch function is passed the result of
-
If documents exists in local storage and the current document is not new, then we check to see if the current document title is the same as the original document title. If it is, then we can safely assume that the user is saving the document with the same title, so we can just overwrite the local storage data with the current document information and the dispatch function is passed the result of
saveDocumentInformation
which will update the redux store with the current document information. If the current document doesn't exist in local storage then just add it to the local storage data, else if the document exists that means the markdown document needs to be overwritten and the ovewrite modal will be displayed.
When the overwrite modal appears, user's must confirm the action before it is executed.
Deleting a Document
When deleting the document, there are two main actions that occur:
- Rendering the modal to confirm the deletion
- The deletion itself.
Within document-action.ts
, the following function is declared:
const deleteDoc = useCallback(() => {
dispatch(
displayModal(
'Are you sure you want to delete the current document and its contents? This action cannot be reversed.',
'Delete Document',
'delete'
)
);
}, []);
In TopBar.tsx
, the delete button will fire the function above, which in turn will render the modal. Once the modal renders and the user confirms the action, the following function will be called:
const confirmDelete = useCallback(() => {
if (!document) return;
if (document.isNewDocument) {
dispatch(deleteDocument());
return;
}
const savedDocuments = localStorage.getItem('savedMarkdown');
if (!savedDocuments) {
dispatch(removeModal());
dispatch(deleteDocument());
return;
}
const savedDocumentsObject = JSON.parse(savedDocuments) as SavedDocument;
delete savedDocumentsObject[document.originalDocumentTitle];
localStorage.setItem('savedMarkdown', JSON.stringify(savedDocumentsObject));
dispatch(removeModal());
dispatch(deleteDocument());
window.dispatchEvent(new Event('storage'));
}, [document]);
In the confirmDelete
function above, the following occurs:
- If there is no document present, return to avoid errors.
- Grab all documents in local storage, and if there are no documents, then delete the document from the redux store.
- If documents exist in local storage, delete the object with the title name, and set local storage to a new object which no longer has the current document information.
- Finally, the dispatch to remove the modal is fired, the document is removed from the Redux store, and the
window
object fires a storage event which ensures the document list reflects this change.
Creating a new Document
The Menu.tsx
component renders a button to create a new document, but since we don't want to immediately lose all saved work, the newDoc
function from the useDocumentAction
hook is used to render a modal that displays the two variants:
- A modal that asks the user if they want to discard a new document.
- A modal that asks the user if they want to discard a saved document.
Either way, the following function from the useModalAction
hook will execute when user confirms the action:
const confirmDiscard = useCallback(() => {
dispatch(setNewDocument());
dispatch(removeModal());
}, []);
The confirmDiscard
function will render the newDocument
markdown text on the page, and hide the modal.
Switching Documents
DocumentList.tsx renders a list of saved documents based on data from the local storage. Each document is represented by the DocumentListItem.tsx component, which when clicked, fires the switchDoc
function from the useDocumentAction
hook. If there is no document on the page and the clicked doc is valid, then it is rendered on the page. Else, a modal that asks the user if they want to switch will be rendered. If the user confirms the action, the following function is called:
const confirmSwitch = useCallback(() => {
const targetDocumentTitle = targetSwitch;
const savedMarkdown = localStorage.getItem('savedMarkdown');
if (!savedMarkdown || !targetDocumentTitle) return;
const savedMarkdownObject = JSON.parse(savedMarkdown) as SavedDocument;
if (!savedMarkdownObject[targetDocumentTitle]) {
dispatch(removeTargetDoc());
dispatch(removeModal());
return;
} else {
dispatch(
changeDocument(
targetDocumentTitle,
savedMarkdownObject[targetDocumentTitle].documentMarkdown
)
);
}
dispatch(removeTargetDoc());
dispatch(removeModal());
}, [document]);
The confirmSwitch
function within the useModalAction
hook executes the following:
- Grabs all documents in local storage and the current document title.
- If no documents exists in local storage, or there is no document to switch to, return.
- If the target document cannot be found in the local storage, ensuring to catch errors, then remove the target document and the modal.
- If all checks had passed at this point, then there is a document to switch to. The
changeDocument
method is passed into the dispatch, taking in the document title and markdown to render on the page. - The modal and target document are removed.
Editing Markdown.
The MarkdownTextArea.tsx component contains the textarea
where users can edit markdown. As users edit the text area, the updateDoc
function from the useDocumentAction
hook is called to update the markdown in the Redux store. Originally I planned to just have this state contained within component itself, however, multiple components around the application needed access to this information; so the state would need to exist within the redux store. updateDoc
simply calls the updateMarkdown
action from the reducer.
One tricky part of editing the document was inserting a tab character when the user presses the tab key. By default, when a user tabs in a text-area, the keyboard navigates to the next tab target. To insert a tab character, a keyDown
event was implemented within the textarea
, see below:
<textarea
data-cy="markdownTextArea"
value={document?.documentMarkdown || ''}
onChange={(e) => updateDoc(e.target.value)}
onBlur={(e) => updateDoc(e.target.value)}
onKeyDown={(e) => {
const textArea = e.target;
if (!(textArea instanceof HTMLTextAreaElement)) return;
if (e.key == 'Tab') {
e.preventDefault();
const start = textArea.selectionStart;
const end = textArea.selectionEnd;
textArea.value =
textArea.value.substring(0, start) +
' ' +
textArea.value.substring(end);
textArea.selectionStart = textArea.selectionEnd = start + 3;
updateDoc(textArea.value);
}
}}
/>
The onKeyDown
attribute placed within the textarea
executes the following:
- Extract the text area from the event target and ensure that it is an HTMLTextAreaElement (Typescript check).
- If the key pressed is the tab key, prevent the default action of leaving the
textarea
. - Grab the start and end of the selection in the text area, then insert three spaces at the start position, and concat the rest of the string after the end position.
- Set the selection start and end to the start position plus 3 (the length of the tab character) to position cursor in its new position.
- Call
updateDoc
to update the markdown in the store.
Preview Markdown
The PreviewTextArea.tsx component extracts the document markdown from the Redux store and uses marked library to parse the markdown into a valid HTML string. The component can be seen below:
export const PreviewTextArea = ({
adjustPreview,
fullPreview,
}: PreviewTextAreaProps) => {
const documentState = useAppSelector((state) => state);
const html = marked.parse(documentState.document?.documentMarkdown || '');
return (
<PreviewTextAreaStyled data-fullpreview={fullPreview}>
<div>
<h1>preview</h1>
<button onClick={adjustPreview} aria-label="toggle full screen preview">
<img src={showPreviewIcon} alt="" />
</button>
</div>
<PreviewText
data-cy="previewTextArea"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }),
}}
></PreviewText>
</PreviewTextAreaStyled>
);
};
The prop fullPreview
will display the preview area as full screen if true, else it will share the screen with the MarkdownTextArea.tsx
component. The dangerouslySetInnerHTML
prop is used to render the HTML string returned from the marked
function. The markdown is first passed through the sanitize
method from the DomPurify
method to sanitize the HTML string.
Conclusion
Overall this was a fun project to work on. I felt that I was able to get a good grasp on Redux fundamentals, discover a new way to render markdown on a web page, and work on a project that was more complex than previous projects I had worked on.
Some things I look to improve include:
- Implement unit test that would have helped me solve some of the bugs I encountered in specific components.
- Migrate from local storage to
IndexedDB
for offline storage, and eventually add support for authentication so users can save their documents to a database to be accessed anywhere.
I would like to discuss the render issues that some may notice. Most actions on the page (menu open, type, etc.) will cause almost the entire page to re-render. As of the current state of the application, since renders are short and efficient, this isn't much of a problem. I believe in the idea of not "over-optimizing"; React is meant to render the application and is built to be very good at that. Introducing multiple optimization techniques when the performance is fine does nothing more than over-complicate our components. Plus, these methods themselves come at a price. Until peformance becomes noticeable issue, it is best to leave it be.
If you find problems or have suggestions, please feel free to open an issue or pull request! Thanks for reading! Please check out the GitHub page for this project for more details!