mxkaske
Home·Craft·Brew

Fancy Area

May 20236 min read

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 duplicate textarea as div 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 at
  • replaceWord(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 the currentWord.startsWith(“@“).

  • We didn't wrap our Command Component around a Popover or CommandDialog (like we did in Fancy 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 the Command.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:

  1. remark-parse: parses markdown content
  2. remark-rehype: turns markdown into HTML and "allowDangerousHtml"
  3. rehype-raw: turns raw embedded HTML into proper HTML nodes
  4. rehype-sanitize: only allows safe HTML nodes
  5. rehype-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:

  1. fancy-area.tsx brings everything together
  2. write.tsx includes the logic for the textarea and combobox
  3. preview.tsx displays the content
  4. use-processor.ts generates JSX out of Markdown
  5. utils.ts includes utility functions for writing
  6. mention.tsx keeps the hover card separate from the preview
  7. data.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.