Sweat. Blood. Tears.
Staring at a screen for countless hours
Rich Text Editors are one of the most challenging aspects of frontend development
I pushed through the pain and built it so you won't have to
Let's dive in
What we're building
Sike.
Let's get a topological view of what you'll build first
You will build a <ContentEditor/>
component that connects to a useContentEditor
hook
The hook will allow getting and setting of the markdown content
Live Demo. Take it for a spin
Seems pretty straightforward, right?
Let's build.
Environment Setup
Spin up a React Repl on Replit.
Alternatively, create a new react app with create-react-app
.
The Component
Install @remirror/react
and remirror
via npm.
npm install remirror @remirror/react
Copy. Paste. Docs if you want
TL;DR, we're just importing and rendering the Remirror
component from @remirror/react
The heavy lifting will be done by the useContentEditor
hook
import { EditorComponent, Remirror, ReactExtensions, UseRemirrorReturn } from "@remirror/react";
import { AnyExtension, RemirrorEventListener } from "remirror";
export function ContentEditor({
editor: { manager, state },
onChange,
}: {
editor: UseRemirrorReturn<ReactExtensions<AnyExtension>>;
onChange: RemirrorEventListener<AnyExtension>;
}) {
return (
<div>
<Remirror
manager={manager}
state={state}
onChange={onChange}
autoFocus
>
<EditorComponent />
</Remirror>
</div>
);
}
Extensions
Editor functionality is based on Remirror Extensions.
BoldExtension
allows us to use bold text formatting
ItalicExtension
likewise
Self-explanatory.
Create a file extensions.ts
and paste the extension config for allowing most aspects of basic markdown syntax.
import { textblockTypeInputRule } from "prosemirror-inputrules";
import { AnyExtension, ExtensionPriority } from "remirror";
import {
BlockquoteExtension,
BoldExtension,
BulletListExtension,
CodeBlockExtension,
CodeExtension,
HardBreakExtension,
HeadingExtension,
ItalicExtension,
LinkExtension,
ListItemExtension,
MarkdownExtension,
OrderedListExtension,
PlaceholderExtension,
StrikeExtension,
TextHighlightExtension,
TrailingNodeExtension,
} from "remirror/extensions";
import languages from "./languages";
/**
* Modifies the Code Block extension creation behavior. Creates a code block after language specification + space/line break.
*/
class ExtendedCodeBlockExtension extends CodeBlockExtension {
createInputRules() {
const regexp = /^```
{% endraw %}
([a-zA-Z]+)?(\n|\s)/;
return [textblockTypeInputRule(regexp, this.type)];
}
}
/**
* A list of allowed Remirror Extensions
*/
const extensions =
(placeholder: string = "") =>
(): Array<AnyExtension> => [
new BoldExtension(),
new ItalicExtension(),
new HeadingExtension(),
new CodeExtension(),
new BlockquoteExtension(),
new LinkExtension({ autoLink: true }),
new StrikeExtension(),
new BulletListExtension({ enableSpine: true }),
new OrderedListExtension(),
new ListItemExtension({
priority: ExtensionPriority.High,
enableCollapsible: true,
}),
new ExtendedCodeBlockExtension({
supportedLanguages: languages,
}),
new TrailingNodeExtension(),
new MarkdownExtension({
copyAsMarkdown: false,
}),
new HardBreakExtension(),
new TextHighlightExtension(),
new PlaceholderExtension({
placeholder,
}),
];
export default extensions;
{% raw %}
Why am I extending CodeBlockExtension
?
The default config is less user-friendly and a bit whacky.
A little bit of placeholder drama, but it makes future configuration easy.
Syntax Highlighting
This was half the headache for me.
Took forever to figure it out, but I did it.
Create a file languages.ts
and paste the following.
tsx
import clike from "refractor/lang/clike.js";
import go from "refractor/lang/go.js";
import java from "refractor/lang/java.js";
import kotlin from "refractor/lang/kotlin.js";
import md from "refractor/lang/markdown.js";
import php from "refractor/lang/php.js";
import python from "refractor/lang/python.js";
import ruby from "refractor/lang/ruby.js";
import rust from "refractor/lang/rust.js";
import swift from "refractor/lang/swift.js";
import typescript from "refractor/lang/typescript.js";
import html from "refractor/lang/xml-doc.js";
const languages = [
typescript,
md,
python,
html,
clike,
java,
php,
swift,
kotlin,
ruby,
go,
rust,
];
export default languages;
Straightforward.
refractor
should have been pre-installed with remirror.
The hook
This is where everything comes together.
Let's start with imports.
ts
import remirrorExtensions from "./extensions";
import { ReactExtensions, useRemirror, UseRemirrorReturn } from "@remirror/react";
import { useCallback, useState } from "react";
import { AnyExtension, RemirrorEventListener, RemirrorEventListenerProps } from "remirror";
And I'm sorry for not separating type imports.
Next, return type.
ts
export interface UseContentEditorReturnType {
editor: UseRemirrorReturn<ReactExtensions<AnyExtension>>;
onChange: RemirrorEventListener<AnyExtension>;
content: string;
setContent: (content: string) => void;
}
editor
: Modified Remirror utility
onChange
: Also Modified Remirror utility
content
: markdown content
setContent
: to control and set the editor content state.
Create the hook function.
ts
export function useContentEditor(
// Initial value
value: string,
// Optional arguments
args?: {
placeholder?: string;
}
): UseContentEditorReturnType {
}
Red squiggly lines.
Endure it.
Create a useState hook for the markdown content
Load the extensions
Configure a new remirror editor instance
ts
const [content, setMarkdownContent] = useState(value);
const extensions = remirrorExtensions(args?.placeholder);
const editor = useRemirror({
extensions,
stringHandler: "markdown",
content: value,
selection: "start",
});
const { onChange, manager } = editor;
Create a modified onEditorChange
function to update the markdown string state
const onEditorChange = useCallback(
(
parameter: RemirrorEventListenerProps<
ReactExtensions<ReturnType<typeof extensions>[number]>
>,
) => {
const markdownContent = parameter.helpers.getMarkdown(parameter.state);
setMarkdownContent(markdownContent);
onChange(parameter);
},
[onChange],
);
Create a setContent
function that sets the markdown state and updates the editor
ts
const setContent = useCallback(
(value: string) => {
manager.view.updateState(
manager.createState({
content: value,
selection: "end",
stringHandler: "markdown",
}),
);
setMarkdownContent(value);
},
[manager],
);
Return.
return {
editor,
onChange: onEditorChange,
setContent,
content,
};
Wrapping up
Let's put everything together.
A little different than the overview in the beginning.
Adjust import paths if necessary.
tsx
import { ContentEditor } from './ContentEditor'
import { useContentEditor } from './ContentEditor/useContentEditor'
const initialValue = `This is a rich text editor demo.
It supports **bold**, _italic_, \`code\`, and ~~strikethrough~~ formatting.
> Blockquotes are supported as well
\`\`\`js
// And so are
console.log("Code blocks with syntax highlighting");
\`\`\`
- Lists
- Work
- As well
1. And numbered ones
2. Behave similarly`
export default function App() {
const { editor, onChange, content, setContent } = useContentEditor(initialValue);
return (
<div className="container">
<h1>Rich text editor demo</h1>
<div>
<button onClick={() => setContent(initialValue)}>Reset</button>
</div>
<div className="content">
<ContentEditor editor={editor} onChange={onChange} />
<pre className="markdown-content">{content}</pre>
</div>
</div>
)
}
I forgot the CSS.
That's yours to take care of
Or go ahead and use mine
We're done
You've just built on of the most challenging things in the entirety of web development.
Let's get in touch.
Top comments (0)