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 duplicatetextareaasdivto 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
CommandInputand 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
Textarealistens 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
CommandComponent around aPopoverorCommandDialog(like we did inFancy Box). The Components would focus automatically and we would lose the ability to continue writing in the textarea. -
The supported
peopleare 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.LoadingComponent. -
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-reactLet'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, Fragment, useEffect, useState } from "react";
import { Mention } from "./mention";
import { jsx, jsxs } from "react/jsx-runtime";
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,
Fragment,
jsx,
jsxs,
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.tsxbrings everything togetherwrite.tsxincludes the logic for the textarea and comboboxpreview.tsxdisplays the contentuse-processor.tsgenerates JSX out of Markdownutils.tsincludes utility functions for writingmention.tsxkeeps the hover card separate from the previewdata.tsincludes 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.