How to build a Notion-like text editor in React and TipTap

Vikram Thyagarajan
16 min readAug 7, 2023
The final output

One of the best experiences you can give your users is the ability to write unbounded. A place for them to hold their thoughts in reserve while they think through important things in their head. We don’t want to steal their words, but just store them in a place for keepsakes. A vault that will hold their words until they need them in their lives.

Unfortunately, this is not the easiest to do in modern frameworks. Text editing brings along with it a lot of issues that need to be tackled starting with a good text editing framework that is customizable, configurable, but also infinitely extensible. It needs to have a clear and crisp API that allows us to mangle the user's cursor, words, UI, and keyboard events in such a way that we can give them the best writing experience without getting in their way. To take this text editor for a test drive, head on to this link.

The framework

Tiptap is a content editing framework that is built on top of a stable API called ProseMirror. When used in conjunction, it gives us a way to easily extend the capabilities of ProseMirror using its plugin system which gives us a good API to work with. It also provides us with a simple set of plugins out of the box that can be found here. We will be using these extensions along with building some of our own in order to come up with the best long-form journal writing experience for users. Let’s get started.

Schemas and basic plugins

A schema is a description of the document that the user is trying to write. In most text editing formats on the web, the main format is HTML. It is extensible, configurable, lightweight, and easily transformable. We need to describe the HTML format of the document that the user is trying to write. In our case, we need to allow for these HTML tags-

Nodes (block nodes that take up a paragraph on the document)

Marks (inline nodes that alter styles on the text in each node)

Extensions (extra features for better UX)

We get this schema out of the box with the StarterKit and a few other extensions. The next extensions we need to add are Menu Nodes that can help the users manipulate these inline and block nodes. The first we will add is the Bubble Menu, which allows users to add marks (bold, italics, underline, etc) to their text nodes when the user selects some text.

Bubble Menu

We need to add an extension from TipTap called the Bubble Menu extension which will add a tooltip whenever the user makes an inline text selection. This is available via the BubbleMenu as BubbleMenuReact component in the @tiptap/react library. We pass in the editor element, a container reference with a DOM element in which the tooltip will be contained.

export const BubbleMenu = ({ editor, containerRef }: BubbleMenuProps) => {
const [selectionType, setSelectionType] = useState<SelectionMenuType>(null);
useEffect(() => {
if (selectionType !== "link") setSelectionType(null);
}, []);
if (!editor || !containerRef.current) return null;
return (
<BubbleMenuReact
pluginKey="bubbleMenu"
editor={editor}
className="bubble-menu"
tippyOptions={{
appendTo: containerRef.current,
}}
>
<SelectionMenu
editor={editor}
selectionType={selectionType}
setSelectionType={setSelectionType}
/>
</BubbleMenuReact>
);
};

We also need to add a Selection Menu that allows the user to paste a link embed when the user clicks on the Link mark in the Bubble Menu.

export interface BubbleMenuProps {
editor: Editor;
containerRef: RefObject<HTMLDivElement>;
}

export type SelectionMenuType = "link" | null;

const SelectionMenu = ({
editor,
selectionType,
setSelectionType,
}: {
editor: Editor;
selectionType: SelectionMenuType;
setSelectionType: (type: SelectionMenuType) => void;
}) => {
switch (selectionType) {
case null:
return (
<>
<button
type="button"
data-test-id="mark-bold"
className={clsx({
active: editor.isActive("bold"),
})}
onClick={() => editor.chain().toggleBold().run()}
>
<BoldIcon />
</button>
<button
type="button"
data-test-id="mark-italic"
className={clsx({
active: editor.isActive("italic"),
})}
onClick={() => editor.chain().toggleItalic().run()}
>
<ItalicIcon />
</button>
<button
type="button"
data-test-id="mark-underline"
className={clsx({
active: editor.isActive("underline"),
})}
onClick={() => editor.chain().toggleUnderline().run()}
>
<UnderlineIcon />
</button>
<button
type="button"
data-test-id="mark-strike"
className={clsx({
active: editor.isActive("strike"),
})}
onClick={() => editor.chain().toggleStrike().run()}
>
<StrikeIcon />
</button>
<button
type="button"
data-test-id="mark-link"
className={clsx({
active: editor.isActive("link"),
})}
onClick={() => {
setSelectionType("link");
}}
>
<LinkIcon />
</button>
</>
);
case "link":
return (
<div className="insert-link-box">
<input
data-test-id="insert-link-value"
autoFocus
type="text"
placeholder="Insert link address"
onKeyDown={(event) => {
if (event.key === "Enter") {
editor
.chain()
.focus()
.setLink({
href: (event.target as HTMLInputElement).value,
target: "_blank",
})
.run();
setSelectionType(null);
}
}}
/>
</div>
);
}
};

This element shows the Bold, Italics, Underline, and Strike marks, and if Link is selected, shows an <input> element that lets the user paste a link embed and highlights it in the text element. When all is said and done, this is how the experience looks like for a user

The Bubble Menu for links in action

Commands Menu

It’s time to move on to Node Creation for our lovely users. The ideal way for a user to create new types of nodes in their documents would be to not take them out of the flow of writing. We want to open up a floating tooltip that shows up when the user types the / key. We give them a list of options to choose from and they select the node they would like to create. An important feature we want to give them is to be able to use this whole menu from just the keyboard.

Unfortunately, for making this kind of a plugin, we need to use some advanced features of TipTap, called CustomNodeViews. This allows us to write a React Component and have that render our CommandMenu. We will use a ProseMirror extension called Suggestion that comes out of the box with TipTap in order to achieve this

const CommandsPlugin = Extension.create({
name: "insertMenu",
addProseMirrorPlugins() {
return [
Suggestion<CommandProps>({
editor: this.editor,
char: "/",
command: ({ editor, range, props }) => {
props.command({ editor, range, props });
},
items: ({ query }) => {
return (
[
{
title: "Heading",
attrs: {
"data-test-id": "insert-heading1",
},
command: ({ editor }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.setNode("heading", { level: 1 })
.run();
},
},
{
title: "Subheading",
attrs: {
"data-test-id": "insert-heading2",
},
command: ({ editor }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Small Subheading",
attrs: {
"data-test-id": "insert-heading3",
},
command: ({ editor }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Quote",
attrs: {
"data-test-id": "insert-quote",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.setBlockquote()
.run();
},
},
{
title: "Bullet List",
attrs: {
"data-test-id": "insert-bullet-list",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.toggleBulletList()
.run();
},
},
{
title: "Numbered List",
attrs: {
"data-test-id": "insert-ordered-list",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.toggleOrderedList()
.run();
},
},
{
title: "Code Block",
attrs: {
"data-test-id": "insert-code",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.setCodeBlock()
.run();
},
},
{
title: "Callout",
attrs: {
"data-test-id": "insert-callout",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.setCallout()
.run();
},
},
{
title: "Image",
attrs: {
"data-test-id": "insert-image",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, { type: "imagePlaceholder" })
.run();
},
},
{
title: "Video",
attrs: {
"data-test-id": "insert-video",
},
command: ({ editor, range }) => {
const selection = editor.view.state.selection;
const from = selection.$from.posAtIndex(0);
const to = selection.$from.posAtIndex(1);
editor
.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, { type: "videoPlaceholder" })
.run();
},
},
] as CommandProps[]
)
.filter((item) => {
return item.title.toLowerCase().startsWith(query.toLowerCase());
})
.slice(0, 10);
},
startOfLine: true,
allow: ({ state, range, editor }) => {
const node = state.selection.$from.node();
if (!node) return false;
return node.textBetween(0, 1) === "/";
},
render: () => {
let component: ReactRenderer<CommandsView, any>, popup: Instance<any>;
return {
onStart: (props) => {
component = new ReactRenderer(CommandsView, {
props,
editor: props.editor,
});
popup = tippy(props.editor.options.element, {
getReferenceClientRect:
props.clientRect as GetReferenceClientRect,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props) => {
component.updateProps(props);
popup.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: ({ event }) => {
if (event.key === "Escape") {
popup.hide();
return true;
}
if (component.ref)
return component.ref.onKeyDown(event as KeyboardEvent);
else return true;
},
onExit: () => {
component.destroy();
popup.destroy();
},
};
},
}),
];
},
});

The important functions to pay attention to are the items and render functions. The items function takes a query string as a parameter and returns an array of possible node types that will be shown to the user. The render function lets us render these items to the user however we want to. In our case, it shows a Tippy tooltip and renders our React Component through the ReactRenderer component that @tiptap/core provides. We also pass in some events like onKeyDown and destroy so our components can handle them as they see fit

This is what our React Component looks like

class CommandsView extends Component<SuggestionProps> {
state = {
selectedIndex: null,
};

componentDidUpdate(oldProps: SuggestionProps) {
if (this.props.items !== oldProps.items) {
this.setState({
selectedIndex: 0,
});
}
}

onKeyDown(event: KeyboardEvent) {
if (event.key === "ArrowUp") {
this.upHandler();
return true;
}

if (event.key === "ArrowDown") {
this.downHandler();
return true;
}

if (event.key === "Enter") {
this.enterHandler();
return true;
}

return false;
}

upHandler() {
this.setState({
selectedIndex:
((this.state.selectedIndex || 0) + this.props.items.length - 1) %
this.props.items.length,
});
}

downHandler() {
this.setState({
selectedIndex:
this.state.selectedIndex === null
? 0
: ((this.state.selectedIndex || 0) + 1) % this.props.items.length,
});
}

enterHandler() {
this.selectItem(this.state.selectedIndex);
}

selectItem(index: number | null) {
const item = this.props.items[index || 0];

if (item) {
this.props.command(item);
}
}

render() {
const { items } = this.props;
return (
<div className="insert-menu">
{items.map((item, index) => {
return (
<button
type="button"
className={`${
index === this.state.selectedIndex ? "active" : ""
}`}
{...item.attrs}
key={index}
onClick={() => this.selectItem(index)}
>
{item.element || item.title}
</button>
);
})}
</div>
);
}
}

It holds the currently selected item in its state and updates the state whenever a KeyDown event is received by it. We also add an active class for the currently selected node to give feedback on the user. When the user hits the Enter key, we add the currently selected node to the document and allow the user to continue writing.

The CommandMenu in action

An important thing to understand while creating and changing nodes is how to use the ProseMirror API to achieve this. TipTap gives us easy and chainable command functions that can be used to manipulate the Editor schema and document. You can find a list of these commands here. Mostly, we just need the editor prop that is available to all plugins in order to run these commands

Change Menu

The final menu element we are going to build is the ChangeMenu. This will help the user convert certain nodes to different types of nodes. So we can convert a paragraph node to a quote, and into a list item without losing the content inside these nodes. This is the final menu element we need, but it is far more complicated than the ones we’ve made before because it means we are making the whole extension from basic TipTap and ProseMirror APIs, so sit tight and grab yourself a warm cup of tea as we break these down

Part 1: Showing the Change Menu View

We want to show a change node icon on the left of every block node. And this time, we won’t be using the React Renderer, but doing this through vanilla javascript. The reason for this is that we want deeper access to the ProseMirror API, and we want more control over the tooltip we show and how it behaves

The change node icon to the top left of every block node

In order for us to show the tooltip at the correct location, we need to understand how TipTap’s TextSelection works. We can explain this through our implementation of the shouldShow function, which describes on every tick whether our menu tooltip should show

  public shouldShow: Exclude<ChangeMenuPluginProps["shouldShow"], null> = ({
view,
state,
}) => {
const { selection } = state;
const { $anchor, empty } = selection;
const disabledContents = [
"imageNode",
"imagePlaceholder",
"videoNode",
"videoPlaceholder",
];
let isDisabled = false;
if ($anchor && $anchor.node(1)) {
const node = $anchor.node(1);
const contents = node.content ? node.content?.toJSON() || [] : [];
isDisabled = contents.find((c: { type: string }) =>
disabledContents.includes(c.type)
);
}
const isRootDepth = $anchor.depth === 1;
const isEmptyTextBlock =
$anchor.parent.isTextblock &&
!$anchor.parent.type.spec.code &&
!$anchor.parent.textContent;

if (
!view.hasFocus() ||
isDisabled ||
!this.editor.isEditable
) {
return false;
}

return true;
};

The current text selection is stored in the editor state as state.selection . Within this selection, we have 2 nodes that describe the range of selection. The $anchor position is the part of the selection that does not move when we extend the cursor. It is the starting position of our selection, while the $from position is the part that changes. From here, we need to start filtering whether this menu should be visible

Once we get the $anchor position, we check its root node’s contents. We need to check the root node because the actual text selection is on the leaf node. In order to know what contents are present in this node, we must select the node at depth 1 and loop through its contents

The hierarchy for a text selection in an ordered list

We then calculate the isDisabled value by looping through the contents and setting isDisabled to true for Video and Image nodes. This is because Image and Video nodes cannot be changed to other nodes because we simply have no way to transform images and videos into paragraphs and vice versa.

Now that we have understood this, we can look at the code for the whole ChangeMenuView

export class ChangeMenuView {
public editor: Editor;

public element: HTMLElement;

public view: EditorView;

public preventHide = false;

public tippy: Instance | undefined;

public tippyOptions?: Partial<Props>;

public shouldShow: Exclude<ChangeMenuPluginProps["shouldShow"], null> = ({
view,
state,
}) => {
const { selection } = state;
const { $anchor, $from, empty } = selection;
const disabledContents = [
"imageNode",
"imagePlaceholder",
"videoNode",
"videoPlaceholder",
];
let isDisabled = false;
if ($anchor && $anchor.node(1)) {
const node = $anchor.node(1);
const contents = node.content ? node.content?.toJSON() || [] : [];
isDisabled = contents.find((c: { type: string }) =>
disabledContents.includes(c.type)
);
}
const isRootDepth = $anchor.depth === 1;
const isEmptyTextBlock =
$anchor.parent.isTextblock &&
!$anchor.parent.type.spec.code &&
!$anchor.parent.textContent;

if (
!view.hasFocus() ||
isDisabled ||
// !empty ||
// !isRootDepth ||
// !isEmptyTextBlock ||
!this.editor.isEditable
) {
return false;
}

return true;
};

constructor({
editor,
element,
view,
tippyOptions = {},
shouldShow,
}: ChangeMenuViewProps) {
this.editor = editor;
this.element = element;
this.view = view;

if (shouldShow) {
this.shouldShow = shouldShow;
}

this.element.addEventListener("mousedown", this.mousedownHandler, {
capture: true,
});
this.editor.on("focus", this.focusHandler);
this.editor.on("blur", this.blurHandler);
this.tippyOptions = tippyOptions;
// Detaches menu content from its current parent
this.element.remove();
this.element.style.visibility = "visible";
}

mousedownHandler = () => {
this.preventHide = true;
};

focusHandler = () => {
// we use `setTimeout` to make sure `selection` is already updated
setTimeout(() => this.update(this.editor.view));
};

blurHandler = ({ event }: { event: FocusEvent }) => {
if (this.preventHide) {
this.preventHide = false;

return;
}

if (
event?.relatedTarget &&
this.element.parentNode?.contains(event.relatedTarget as Node)
) {
return;
}

this.hide();
};

tippyBlurHandler = (event: FocusEvent) => {
this.blurHandler({ event });
};

createTooltip() {
const { element: editorElement } = this.editor.options;
const editorIsAttached = !!editorElement.parentElement;

if (this.tippy || !editorIsAttached) {
return;
}

this.tippy = tippy(editorElement, {
duration: 0,
getReferenceClientRect: null,
content: this.element,
interactive: true,
trigger: "manual",
placement: "right-start",
...this.tippyOptions,
});

// maybe we have to hide tippy on its own blur event as well
if (this.tippy.popper.firstChild) {
(this.tippy.popper.firstChild as HTMLElement).addEventListener(
"blur",
this.tippyBlurHandler
);
}
}

update(view: EditorView, oldState?: EditorState) {
const { state } = view;
const { doc, selection } = state;
const isSame =
oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection);

if (isSame) {
return;
}

this.createTooltip();

const shouldShow = this.shouldShow?.({
editor: this.editor,
view,
state,
oldState,
});

if (!shouldShow) {
this.hide();

return;
}

this.tippy?.setProps({
getReferenceClientRect:
this.tippyOptions?.getReferenceClientRect ||
(() => {
const from = selection.$from.posAtIndex(0);
const boundaries = posToDOMRect(view, 1, 1);
const nodeRect = posToDOMRect(view, from, from);
return {
...nodeRect,
left: boundaries.left,
right: boundaries.right,
};
}),
});

this.show();
}

show() {
this.tippy?.show();
}

hide() {
this.tippy?.hide();
}

destroy() {
if (this.tippy?.popper.firstChild) {
(this.tippy.popper.firstChild as HTMLElement).removeEventListener(
"blur",
this.tippyBlurHandler
);
}
this.tippy?.destroy();
this.element.removeEventListener("mousedown", this.mousedownHandler, {
capture: true,
});
this.editor.off("focus", this.focusHandler);
this.editor.off("blur", this.blurHandler);
}
}

The main function to understand in this view is the update function that runs whenever the editor receives focus. We create a tooltip, check whether it should show, and then show the tooltip in the correct coordinates. We do some extra math to calculate the boundary Rect so that our tooltip never overlaps with text that the user has written. So our left and right values are always out of bounds of the actual text content.

Part 2: Creating an extension for the ChangeMenu

Next, we hook our vanilla ChangeMenu to the TipTap plugin system so our tooltip can show up properly in the view when required

const ChangeMenuPlugin = (options: ChangeMenuPluginProps) => {
return new Plugin({
key:
typeof options.pluginKey === "string"
? new PluginKey(options.pluginKey)
: options.pluginKey,
view: (view) => new ChangeMenuView({ view, ...options }),
});
};

export const ChangeMenuReact = Extension.create({
name: "changeMenu",
addOptions() {
return {
element: null,
tippyOptions: {},
pluginKey: "changeMenu",
shouldShow: null,
};
},
addProseMirrorPlugins() {
return [
ChangeMenuPlugin({
pluginKey: this.options.pluginKey,
editor: this.editor,
element: this.options.element,
tippyOptions: this.options.tippyOptions,
shouldShow: this.options.shouldShow,
}),
];
},
});

export interface ChangeMenuProps {
containerRef: RefObject<HTMLDivElement>;
className?: string;
editor: Editor;
tippyOptions?: Partial<Props>;
shouldShow?:
| ((props: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
}) => boolean)
| null;
}

export type MenuItemProps = {
title: string;
subtitle?: string;
attrs: any;
command: ({
editor,
range,
}: {
editor: Editor;
range: { from: number; to: number };
}) => void;
}[];

This is pretty standard syntax for writing a plugin with TipTap. We define it as a ProseMirror plugin so we have access to the editor state and other commands that helps us use the ProseMirror API. We also define some types and interfaces that describe our components

Part 3: Showing the different nodes on click of ChangeMenu

We want to now register this plugin in a React component that we can drop into our JSX and have it show the tooltip. The ChangeMenu itself handles the showing and hiding of the tooltip, all that is remaining is handling the click of the icon, showing the nodes, and the click command to change the node

export const ChangeMenu = (props: PropsWithChildren<ChangeMenuProps>) => {
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [showList, setShowList] = useState(false);

useEffect(() => {
if (!element) {
return;
}

if (props.editor.isDestroyed) {
return;
}

const pluginKey = "changeMenu";
const { editor, tippyOptions = {}, shouldShow = null } = props;

const plugin = ChangeMenuPlugin({
editor,
element,
pluginKey,
shouldShow,
tippyOptions,
});

editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
}, [props.editor, element]);

const menus: MenuItemProps = [
{
title: "Paragraph",
attrs: {
"data-test-id": "set-paragraph",
},
command: ({ editor, range }) => {
editor
.chain()
.focus()
.clearNodes()
.toggleNode("paragraph", "paragraph", {})
.run();
},
},
{
title: "Heading",
attrs: {
"data-test-id": "set-heading1",
},
command: ({ editor, range }) => {
editor.chain().focus().setNode("heading", { level: 1 }).run();
},
},
{
title: "Subheading",
attrs: {
"data-test-id": "set-heading2",
},
command: ({ editor }) => {
editor.chain().focus().setNode("heading", { level: 2 }).run();
},
},
{
title: "Small Subheading",
attrs: {
"data-test-id": "set-heading3",
},
command: ({ editor }) => {
editor.chain().focus().setNode("heading", { level: 3 }).run();
},
},
{
title: "Quote",
attrs: {
"data-test-id": "set-quote",
},
command: ({ editor, range }) => {
editor.chain().focus().clearNodes().setBlockquote().run();
},
},
{
title: "Bullet List",
attrs: {
"data-test-id": "set-bullet-list",
},
command: ({ editor, range }) => {
editor.chain().focus().clearNodes().toggleBulletList().run();
},
},
{
title: "Numbered List",
attrs: {
"data-test-id": "set-ordered-list",
},
command: ({ editor, range }) => {
editor.chain().focus().toggleOrderedList().run();
},
},
{
title: "Code Block",
attrs: {
"data-test-id": "set-code",
},
command: ({ editor, range }) => {
editor.chain().focus().setCodeBlock().run();
},
},
{
title: "Callout",
attrs: {
"data-test-id": "set-callout",
},
command: ({ editor, range }) => {
editor.chain().focus().setCallout().run();
},
},
];

return (
<div
ref={setElement}
className={props.className}
data-test-id="change-block"
style={{ visibility: "hidden" }}
>
<ChangeIcon onClick={() => setShowList(!showList)} />
{showList ? (
<div className="block-menu">
{menus.map(({ attrs, title, subtitle, command }) => {
return (
<div
key={title}
className="menu-item"
{...attrs}
onClick={() => {
setShowList(false);
const { selection } = props.editor.state;
const $anchor = selection.$anchor;
const range = {
from: $anchor.posAtIndex(0, 1),
to: $anchor.posAtIndex(1, 1),
};
command({ editor: props.editor, range });
}}
>
{title}
</div>
);
})}
</div>
) : null}
</div>
);
};

In this code, we take a DOM element as part of our props, pass it on to a ref, and register our plugin against that ref. When we show the list of nodes, we run a click command that is described for each node type and execute it when it is clicked. We also add some CSS classes so we can style these so they look good while the user is using them

This is what that looks when correctly hooked up.

The ChangeMenu in action

Conclusion

In this blog post, we just covered some of the basics of building your own text editor, but a lot more has been left out, most importantly images and video nodes, and how we can further take TipTap and ProseMirror to build more complicated workflows that can help users with long-form writing.

I’ll take a few more blog posts and dive deeper into these concepts, so stay tuned for more content on this. Until then, keep writing, write letters to your friends, and spread beauty around the world. I’ll see you next time 😁

--

--

Vikram Thyagarajan

Software Developer with clients like PepsiCo, Unilever and McKinsey & Co. Passionate about Technology, Travel and Music. Speaks of self in 3rd person.