Supports markdown.
The Textarea is inspired by GitHub's PR comment section. The impressive part is the @mention support including hover cards in the preview. The goal is to reproduce it without text editor library.
UI
Let's keep it short and use ui.shadcn components, which are mainly radix-ui primitives, styled with tailwindcss.
The following components are being used:
To style the preview, we use the @tailwindcss/typography
plugin including the prose
utility class.
A Combobox inside the Textarea
Getting the right keyboard, mouse and touch events and keeping the textarea focus while navigating through the mention Combobox required lots of work and testing. If you recognize any unexpected behavior, feel free to create a GitHub Issue.
For our implementation, three utility functions have been created:
getCaretCoordinates()
: creates a duplicatetextarea
asdiv
to return the current{ top, left, height }
properties of the caret (cheat seen in textarea-caret-position)getCurrentWord()
: returns the current word where the caret is atreplaceWord(value)
: replaces the word where the caret is at with the new word
Let's have a look at a simplified version of the Write
Component:
"use client";
import React, { useRef, useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { getCaretCoordinates, getCurrentWord, replaceWord } from "./utils";
import { people } from "./data";
export function Write() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [commandValue, setCommandValue] = useState("");
const [textValue, setTextValue] = useState("");
const onTextValueChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
// checks if getCurrentWord().startsWith("@") and updates dropdown position based on getCaretCoordinates()
// also updates the textValue state
};
const onCommandSelect = (value: string) => {
// replaceWord() getCurrentWord() with the `value` and hide dropdown
};
const handleMouseDown = (e: Event) => {
// prevents default and stops propagating event, otherwise textarea would lose focus
};
const handleSectionChange = (e: Event) => {
// detects caret position changes and adapts dropdown visiblity based on the getCurrentWord()
};
const handleBlur = (e: Event) => {
// hides dropdown when textarea loses focus
};
const handleKeyDown = (e: KeyboardEvent) => {
// prevent default if dropdown is visible and dispatch keyboard events to input
// allows us to support keyboard navigation without changing the carets position
};
useEffect(() => {
textareaRef.current?.addEventListener("keydown", handleKeyDown);
textareaRef.current?.addEventListener("blur", handleBlur);
documentRef.current?.addEventListener(
"selectionchange",
handleSectionChange
);
dropdownRef.current?.addEventListener("mousedown", handleMouseDown);
return () => {
textareaRef.current?.removeEventListener("keydown", handleKeyDown);
textareaRef.current?.removeEventListener("blur", handleBlur);
documentRef.current?.removeEventListener(
"selectionchange",
handleSectionChange
);
dropdownRef.current?.removeEventListener("mousedown", handleMouseDown);
};
}, [handleBlur, handleKeyDown, handleClick, handleSectionChange]);
return (
<div className="relative w-full">
<Textarea
ref={textareaRef}
value={textValue}
onChange={onTextValueChange}
/>
<Command
ref={dropdownRef}
className="absolute hidden h-auto max-h-32 max-w-min overflow-y-scroll"
>
<div className="hidden">
{/* REMINDER: className="hidden" won't hide the SearchIcon and border */}
<CommandInput ref={inputRef} value={commandValue} />
</div>
<CommandList>
<CommandGroup className="max-w-min overflow-auto">
{people.map((p) => {
return (
<CommandItem
key={p.username}
value={p.username}
onSelect={onCommandSelect}
>
{p.username}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</div>
);
}
Important implementation details are:
-
We hide the
CommandInput
and update the value via state updates. That way, it allows us to use the cmdk package with our custom use case under the hood. -
We propagate keyboard events from the textarea to the input field when dropdown is visible with:
inputRef.current?.dispatchEvent(new KeyboardEvent("keydown", e))
-
The
Textarea
listens to the caret position and the current word where the caret actually is and will be displayed whenever thecurrentWord.startsWith(“@“)
. -
We didn't wrap our
Command
Component around aPopover
orCommandDialog
(like we did inFancy Box
). The Components would focus automatically and we would lose the ability to continue writing in the textarea. -
The supported
people
are statically written inside of the code (see data.ts). A re-iteration of that Fancy Area Component could include dynamic data fetching via API and therefore using theCommand.Loading
Component. -
To replace a word, we are using the deprecated, but still heavily used and yet supported function:
document.execCommand("insertText", false, value);
The reason: it easily supports undo. Please contact me if you know a simple non-deprecated way to do the same.
Transform Markdown into React
The transformation is mainly handled by rehype
plugins.
npm i unified remark-parse remark-rehype rehype-raw rehype-sanitize rehype-react
Let's break all the plugins down!
The following steps transform the user input into valid react
components:
remark-parse
: parses markdown contentremark-rehype
: turns markdown into HTML and"allowDangerousHtml"
rehype-raw
: turns raw embedded HTML into proper HTML nodesrehype-sanitize
: only allows safe HTML nodesrehype-react
: transforms HTML into react components
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeReact from "rehype-react";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { createElement, useEffect, useState } from "react";
import { Mention } from "./mention";
export function useProcessor(md: string) {
const [content, setContent] = useState<React.ReactNode>(null);
// wrap words starting with '@' with custom element
// e.g. "@jack" turns into `<mention handle="jack">@jack</mention>`
const mentionRegex = /@(\w+)/g;
const text = md.replace(mentionRegex, '<mention handle="$1">@$1</mention>');
useEffect(() => {
unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize, {
...defaultSchema,
tagNames: [...defaultSchema.tagNames!, "mention"],
attributes: {
...defaultSchema.attributes,
mention: ["handle"],
},
})
// @ts-expect-error because mention is not a valid html-tag
.use(rehypeReact, {
createElement,
components: {
mention: Mention,
},
})
.process(text)
.then((file) => {
setContent(file.result);
});
}, [text]);
return content;
}
We are using a regular expression (/@(\w+)/g
) to wrap all the words, starting with "@", inside of a custom <mention>
element. This also means that we need to tell rehype-sanitize
that this specific html-tag and its corresponding attribute handle
is safe as well as extending the rehype-react
object that maps tag names to components. In our case { mention: Mention }
.
The logic can be used for any custom Component!
We now can safely import the users content with:
const Component = useProcessor(markdownContent);
Mention HoverCard
The Mention
Component that is being used in the preview is a copy cat of shadcn example. This is also why the Avatar
Component is being used. Period.
Conclusion
In total, 7 files have been created:
fancy-area.tsx
brings everything togetherwrite.tsx
includes the logic for the textarea and comboboxpreview.tsx
displays the contentuse-processor.ts
generates JSX out of Markdownutils.ts
includes utility functions for writingmention.tsx
keeps the hover card separate from the previewdata.ts
includes the list of allowed handles and user properties
If it finds interest, I'll will update the Components to make it more reusable.
The source code is available on GitHub.