Building a Medium-style editor with Tiptap
This is part 2 of a series on how this blog is built. Part 1 covered the reasons and the stack. This one is about the part I care about most: the editor.
The brief I gave myself was one sentence. It should feel like writing on Medium: a blank page, a title, and controls that only show up when I need them.
[Screenshot to add: The empty editor: title field, the "Tell your story…" placeholder and nothing else]
What I took from Medium
Medium's editor has no permanent toolbar. Three ideas do all the work, and I copied all three.
A "+" on empty lines. Put the cursor on a blank line and a round button appears to its left. It opens a row of things to insert.
A toolbar on selected text. Select a few words and the formatting buttons float above them.
A quiet page. No panels, no sidebars. Even the placeholder, "Tell your story…", is a nod to the original.
I added one thing Medium does not have: a slash menu. Typing / on a line opens a list of blocks, which is faster once your hands are already on the keyboard.
Why Tiptap
Tiptap is a headless editor built on ProseMirror. Headless means it gives you the document model, the commands and the keyboard behaviour, and no interface at all. Every button and menu in my editor is my own React component.
That was exactly the split I wanted. I did not want to write a text editor from scratch, and I did not want somebody else's toolbar either. I only use the open-source packages.
The editor is created with a list of extensions. Most of them are shared with the server, which uses the same list to render published posts. Two are editor-only:
const extensions = useMemo(
() => [
...getBaseExtensions(),
Placeholder.configure({ placeholder: "Tell your story…" }),
SlashCommand.configure({
onPickImage: () => fileInput.current?.click(),
onAddYoutube: addYoutube,
}),
],
[],
);The "+" menu
Tiptap ships a FloatingMenu component that shows up on empty lines. Mine holds a single round "+" button; pressing it opens a row of insert buttons: image, YouTube video, code block, quote, bullet list and divider.
[Screenshot to add: The "+" button on an empty line, opened to show the insert row]
The slash menu
The slash menu is a small Tiptap extension built on the Suggestion utility, the same one people use for @-mentions. Each entry is a title, a hint, an icon and a function to run:
const items: SlashItem[] = [
{
title: "Heading 2",
hint: "Section heading",
icon: Heading2,
run: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(),
},
{
title: "Image",
hint: "Upload an image or GIF",
icon: ImageIcon,
run: ({ editor, range }, options) => {
editor.chain().focus().deleteRange(range).run();
options.onPickImage();
},
},
// ...
];Every run starts the same way: deleteRange(range) removes the /head you typed, then the real command runs. Filtering is one line, matching what you typed against the titles:
items: ({ query }) =>
items.filter((i) => i.title.toLowerCase().includes(query.toLowerCase())).slice(0, 9),The menu itself is a React component rendered outside the editor and positioned under the cursor with Floating UI, so it flips above the line when there is no room below.
[Screenshot to add: The slash menu open after typing "/", with the list of blocks]
The selection toolbar
Selecting text shows a toolbar with bold, italic, underline, strikethrough, inline code, highlight, text colour, link, headings, quote and lists. It is Tiptap's BubbleMenu, with a rule for when it may appear:
shouldShow={({ editor, state, from, to }) =>
// Real text only: not a bare cursor, an empty line or a selected image.
state.doc.textBetween(from, to).length > 0 &&
!editor.isActive("codeBlock") &&
!editor.isActive("image")
}The part I like is that the toolbar changes shape. Press the link button and the buttons are replaced by a text field, in the same floating box. Press the colour button and they are replaced by a colour panel. There is no dialog and no second popup; the toolbar becomes the form, then goes back.
[Screenshot to add: The selection toolbar above some selected text]
[Screenshot to add: The same toolbar switched to the link field, and to the colour panel]
One green button for headings
The site's own headings have a green highlighter stroke under them. I wanted to use that in posts too, so there is a custom mark for it. It only appears in the toolbar when the selection is inside a heading:
const Marker = Mark.create({
name: "marker",
parseHTML() {
return [{ tag: "span.marker" }];
},
renderHTML() {
return ["span", { class: "marker" }, 0];
},
});That is a complete Tiptap mark. It wraps the text in a span with a class, and a few lines of CSS draw the stroke.
Images and videos with captions
Pasting an image, dropping one onto the page, or picking one from the "+" menu all end up in the same function. It uploads the file and inserts the image when the upload finishes:
handlePaste: (_view, event) => {
const files = Array.from(event.clipboardData?.files ?? []).filter((f) =>
f.type.startsWith("image/"),
);
if (files.length === 0) return false;
void insertImageFiles(files);
return true;
},Tiptap's image extension has no captions, so I extended it. The image's title attribute holds the caption. With a caption the image renders as a figure with a figcaption; without one it stays a plain img:
const CaptionedImage = Image.extend({
renderHTML({ HTMLAttributes }) {
const { title, ...attrs } = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes);
const caption = typeof title === "string" ? title.trim() : "";
if (!caption) return ["img", attrs];
return ["figure", {}, ["img", attrs], ["figcaption", {}, caption]];
},
});Clicking an image shows a small menu with two fields: a description for screen readers and the caption. YouTube embeds work the same way, with their own caption.
[Screenshot to add: A selected image with the description and caption fields open]
Code blocks
Code blocks are highlighted while you type, using lowlight. A small label sits on the top right corner of the block showing its language, and opens a list to change it.
[Screenshot to add: A code block with the language list open]
By default the language is detected, which caused one of the problems below.
The problems that took the longest
Buttons that stole the selection
A floating toolbar has a built-in trap. You select a word and press Bold, and the selection vanishes before the command runs. Pressing a button moves focus to the button, and an editor without focus has no selection to format.
The fix is one line, applied to every button in every menu:
// Keep the editor selection when a menu button is pressed.
const keepSelection = (e: React.MouseEvent) => e.preventDefault();Calling preventDefault on mousedown stops the focus from moving, while the click still fires.
Language detection that guessed wrong
Automatic detection tries the code against every grammar it knows and picks the best score. On a three-line snippet that goes badly: CSS and INI match almost anything, so a short piece of some other language can come out coloured as CSS.
The fix was to narrow the choice. Detection now only picks between the languages the editor actually offers, minus CSS:
// Guessing among every grammar goes wrong on short snippets (CSS and INI match
// almost anything), so detection only chooses between these.
const DETECTABLE = CODE_LANGUAGES.map((l) => l.value).filter((l) => l !== "css");
export const detectLanguage = (code: string) => lowlight.highlightAuto(code, { subset: DETECTABLE });If the guess is still wrong, the language list on the block overrides it.
Menus that would not go away
With a "+" row, a link field, a colour panel, image fields and a language list, it was easy to end up with something open in the wrong place: move the cursor to another paragraph and the link field from the last one is still there.
Rather than track each case, everything closes whenever the selection changes:
useEffect(() => {
const close = () => {
setInsertOpen(false);
setLinkDraft(null);
setColorOpen(false);
setImageFieldsOpen(false);
setLanguageOpen(false);
};
editor.on("selectionUpdate", close);
return () => {
editor.off("selectionUpdate", close);
};
}, [editor]);It is blunt, and it matches how Medium feels: the moment you go back to writing, the interface is gone.
Links people actually type
People type example.com, not https://example.com. A bare host name in a link is treated by the browser as a path on the current site, so the link breaks. The link field adds the scheme when there is none:
target.setLink({ href: /^[a-z][a-z\d+.-]*:|^[/#]/i.test(href) ? href : `https://${href}` }).run();What I would still improve
Images are uploaded exactly as they are; the editor does not resize or compress them. There are no tables. Both are things I can live without for now, and both are easy to add because of how Tiptap is built: each would be one more extension in the shared list.
Part 3 follows that shared list to the other side: how the JSON the editor produces becomes the page a reader sees.
The whole series
Building a Medium-style editor with Tiptap (this post)
A one-person admin: Google sign-in, a sign-in log and direct uploads