neetoEditor is a rich text editor used across neeto products. It is built on Tiptap, an open-source headless content editor, and offers a seamless and customizable solution for rich text editing.
The decision to use Tiptap as the foundational framework for neetoEditor is based on its flexibility. Tiptap simplifies the complex Prosemirror syntax into simple JavaScript classes. In this blog post, we'll walk you through the process of building an Embed extension using Tiptap.
The Embed extension enables embedding of videos from YouTube, Vimeo, Loom and NeetoRecord.
If you think of the document as a tree, every content type in Tiptap is a Node. Examples of nodes include paragraphs, headings, and code blocks. Here, we are creating a new "embed" node.
import { Node } from "@tiptap/core";
export default Node.create({
name: "embed", // A unique identifier for the Node
group: "block", // Belongs to the "block" group of extensions
//...
});
Attributes store extra information about a node and are rendered as HTML attributes by default. They are parsed from the content during initialization.
const Embed = Node.create({
//...
addAttributes() {
return {
src: { default: null },
title: { default: null },
frameBorder: { default: "0" },
allow: {
default:
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
},
allowfullscreen: { default: "allowfullscreen" },
figheight: {
default: 281,
parseHTML: element => element.getAttribute("figheight"),
},
figwidth: {
default: 500,
parseHTML: element => element.getAttribute("figwidth"),
},
};
},
});
These attributes customize the video embed behavior:
The renderHTML function controls how an extension is rendered to HTML.
import { Node, mergeAttributes } from "@tiptap/core";
const Embed = Node.create({
//...
renderHTML({ HTMLAttributes, node }) {
const { figheight, figwidth } = node.attrs;
return [
"div",
{
class: `neeto-editor__video-wrapper neeto-editor__video--${align}`,
},
[
"div",
{
class: "neeto-editor__video-iframe",
style: `width: ${figwidth}px; height: ${figheight}px;`,
},
[
"iframe",
mergeAttributes(this.options.HTMLAttributes, {
...HTMLAttributes,
}),
],
],
];
},
});
This renders the following HTML content:
<div class="neeto-editor__video-wrapper neeto-editor__video--center">
<div class="neeto-editor__video-iframe" style="width: 281px;height: 500px">
<iframe
src="<src of the embed>"
title="<title of the embed>"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen="allowfullscreen"
figheight="281"
figwidth="500"
align="center"
></iframe>
</div>
</div>
The parseHTML function loads the editor document from HTML by receiving an HTML DOM element as input and returning an object with attributes and their values.
const Embed = Node.create({
//...
parseHTML() {
return [{ tag: "iframe[src]" }];
},
});
This ensures that whenever Tiptap encounters a <iframe>
tag with an src
attribute, our custom "embed" Node renders our custom UI.
Commands help us to easily modify or alter a selection programmatically. For our embed extension, let's write a command to insert the embed node.
const Embed = Node.create({
//...
addCommands() {
return {
setExternalVideo:
options =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: options }),
};
},
});
This is how a command can be executed:
editor
.setExternalVideo({ src: "https://www.youtube.com/embed/3sQv3Xh3Gt4" })
.run();
Node views in TipTap enable customization for interactive nodes in your editor.
You can learn more about Node views with React here.
This is how your node extension could look like:
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import Component from "./Component.jsx";
export default Node.create({
// configuration …
addNodeView() {
return ReactNodeViewRenderer(Component);
},
});
Note: The
ReactNodeViewRenderer
passes a few very helpful props to your custom React component.
This is how our Embed component looks like:
import React from "react";
import { NodeViewWrapper } from "@tiptap/react";
import { mergeRight } from "ramda";
import { Resizable } from "re-resizable";
import Menu from "../Image/Menu";
const EmbedComponent = ({
node,
editor,
getPos,
updateAttributes,
deleteNode,
}) => {
const { figheight, figwidth, align } = node.attrs;
const { view } = editor;
let height = figheight;
let width = figwidth;
const handleResize = (_event, _direction, ref) => {
height = ref.offsetHeight;
width = ref.offsetWidth;
view.dispatch(
view.state.tr.setNodeMarkup(
getPos(),
undefined,
mergeRight(node.attrs, {
figheight: height,
figwidth: width,
height,
width,
})
)
);
editor.commands.focus();
};
return (
<NodeViewWrapper
className={`neeto-editor__video-wrapper neeto-editor__video--${align}`}
>
<Resizable
lockAspectRatio
className="neeto-editor__video-iframe"
size={{ height, width }}
onResizeStop={handleResize}
>
<Menu {...{ align, deleteNode, editor, updateAttributes }} /> // Menu
component to handle alignment and delete
<iframe {...node.attrs} />
</Resizable>
</NodeViewWrapper>
);
};
export default EmbedComponent;
The NodeViewWrapper
component is a wrapper for the custom component provided
by TipTap. The Resizable
component is used to resize the embed node.
Here's the final output of the Embed extension in neetoEditor:
import { Node, mergeAttributes, PasteRule } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { TextSelection } from "prosemirror-state";
import { COMBINED_REGEX } from "common/constants";
import EmbedComponent from "./EmbedComponent";
import { validateUrl } from "./utils";
export default Node.create({
name: "embed"
addOptions() {
return { inline: false, HTMLAttributes: {} };
},
inline() {
return this.options.inline;
},
group() {
return this.options.inline ? "inline" : "block";
},
addAttributes() {
return {
src: { default: null },
title: { default: null },
frameBorder: { default: "0" },
allow: {
default:
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
},
allowfullscreen: { default: "allowfullscreen" },
figheight: {
default: 281,
parseHTML: element => element.getAttribute("figheight"),
},
figwidth: {
default: 500,
parseHTML: element => element.getAttribute("figwidth"),
},
align: {
default: "center",
parseHTML: element => element.getAttribute("align"),
},
};
},
parseHTML() {
return [{ tag: "iframe[src]" }];
},
renderHTML({ HTMLAttributes, node }) {
const { align, figheight, figwidth } = node.attrs;
return [
"div",
{
class: `neeto-editor__video-wrapper neeto-editor__video--${align}`,
},
[
"div",
{
class: "neeto-editor__video-iframe",
style: `width: ${figwidth}px; height: ${figheight}px;`,
},
[
"iframe",
mergeAttributes(this.options.HTMLAttributes, {
...HTMLAttributes,
}),
],
],
];
},
addNodeView() {
return ReactNodeViewRenderer(EmbedComponent);
},
addCommands() {
return {
setExternalVideo:
options =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: options }),
};
},
addPasteRules() {
return [
new PasteRule({
find: COMBINED_REGEX,
handler: ({ state, range, match }) => {
state.tr.delete(range.from, range.to);
state.tr.setSelection(
TextSelection.create(state.doc, range.from + 1)
);
const validatedUrl = validateUrl(match[0]);
if (validatedUrl) {
const node = state.schema.nodes["embed"].create({
src: validatedUrl,
});
state.tr.insert(range.from, node);
state.tr.insert(
range.from + node.nodeSize + 1,
state.schema.nodes.paragraph.create()
);
state.tr.setSelection(
TextSelection.create(state.tr.doc, range.from + node.nodeSize + 1)
);
}
},
}),
];
},
});
If this blog was helpful, check out our full blog archive.