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.
What is the Embed extension?
The Embed extension enables embedding of videos from YouTube, Vimeo, Loom and NeetoRecord.
Implementation
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.
1import { Node } from "@tiptap/core"; 2 3export default Node.create({ 4 name: "embed", // A unique identifier for the Node 5 group: "block", // Belongs to the "block" group of extensions 6 7 //... 8});
Attributes
Attributes store extra information about a node and are rendered as HTML attributes by default. They are parsed from the content during initialization.
1const Embed = Node.create({ 2 //... 3 4 addAttributes() { 5 return { 6 src: { default: null }, 7 8 title: { default: null }, 9 10 frameBorder: { default: "0" }, 11 12 allow: { 13 default: 14 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", 15 }, 16 17 allowfullscreen: { default: "allowfullscreen" }, 18 19 figheight: { 20 default: 281, 21 parseHTML: element => element.getAttribute("figheight"), 22 }, 23 24 figwidth: { 25 default: 500, 26 parseHTML: element => element.getAttribute("figwidth"), 27 }, 28 }; 29 }, 30});
These attributes customize the video embed behavior:
- src: Specify the URL of the video you want to embed.
- title: Add additional information about the video (optional).
- frameBorder: Set to "0" for seamless integration (default).
- allow: Define various permissions for optimal video experience (default value provided).
- allowfullscreen: Enable fullscreen mode (default).
- figheight & figwidth: Control the video frame's size.
Render HTML
The renderHTML function controls how an extension is rendered to HTML.
1import { Node, mergeAttributes } from "@tiptap/core"; 2 3const Embed = Node.create({ 4 //... 5 renderHTML({ HTMLAttributes, node }) { 6 const { figheight, figwidth } = node.attrs; 7 8 return [ 9 "div", 10 { 11 class: `neeto-editor__video-wrapper neeto-editor__video--${align}`, 12 }, 13 [ 14 "div", 15 { 16 class: "neeto-editor__video-iframe", 17 style: `width: ${figwidth}px; height: ${figheight}px;`, 18 }, 19 [ 20 "iframe", 21 mergeAttributes(this.options.HTMLAttributes, { 22 ...HTMLAttributes, 23 }), 24 ], 25 ], 26 ]; 27 }, 28});
This renders the following HTML content:
1<div class="neeto-editor__video-wrapper neeto-editor__video--center"> 2 <div class="neeto-editor__video-iframe" style="width: 281px;height: 500px"> 3 <iframe 4 src="<src of the embed>" 5 title="<title of the embed>" 6 frameborder="0" 7 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 8 allowfullscreen="allowfullscreen" 9 figheight="281" 10 figwidth="500" 11 align="center" 12 ></iframe> 13 </div> 14</div>
Parse HTML
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.
1const Embed = Node.create({ 2 //... 3 parseHTML() { 4 return [{ tag: "iframe[src]" }]; 5 }, 6});
This ensures that whenever Tiptap encounters a <iframe> tag with an src attribute, our custom "embed" Node renders our custom UI.
Commands
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.
1const Embed = Node.create({ 2 //... 3 addCommands() { 4 return { 5 setExternalVideo: 6 options => 7 ({ commands }) => 8 commands.insertContent({ type: this.name, attrs: options }), 9 }; 10 }, 11});
This is how a command can be executed:
1editor 2 .setExternalVideo({ src: "https://www.youtube.com/embed/3sQv3Xh3Gt4" }) 3 .run();
NodeView
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:
1import { Node } from "@tiptap/core"; 2import { ReactNodeViewRenderer } from "@tiptap/react"; 3import Component from "./Component.jsx"; 4 5export default Node.create({ 6 // configuration … 7 8 addNodeView() { 9 return ReactNodeViewRenderer(Component); 10 }, 11});
Note: The ReactNodeViewRenderer passes a few very helpful props to your custom React component.
This is how our Embed component looks like:
1import React from "react"; 2 3import { NodeViewWrapper } from "@tiptap/react"; 4import { mergeRight } from "ramda"; 5import { Resizable } from "re-resizable"; 6 7import Menu from "../Image/Menu"; 8 9const EmbedComponent = ({ 10 node, 11 editor, 12 getPos, 13 updateAttributes, 14 deleteNode, 15}) => { 16 const { figheight, figwidth, align } = node.attrs; 17 const { view } = editor; 18 let height = figheight; 19 let width = figwidth; 20 21 const handleResize = (_event, _direction, ref) => { 22 height = ref.offsetHeight; 23 width = ref.offsetWidth; 24 view.dispatch( 25 view.state.tr.setNodeMarkup( 26 getPos(), 27 undefined, 28 mergeRight(node.attrs, { 29 figheight: height, 30 figwidth: width, 31 height, 32 width, 33 }) 34 ) 35 ); 36 editor.commands.focus(); 37 }; 38 39 return ( 40 <NodeViewWrapper 41 className={`neeto-editor__video-wrapper neeto-editor__video--${align}`} 42 > 43 <Resizable 44 lockAspectRatio 45 className="neeto-editor__video-iframe" 46 size={{ height, width }} 47 onResizeStop={handleResize} 48 > 49 <Menu {...{ align, deleteNode, editor, updateAttributes }} /> // Menu 50 component to handle alignment and delete 51 <iframe {...node.attrs} /> 52 </Resizable> 53 </NodeViewWrapper> 54 ); 55}; 56 57export 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.
Putting it all together
Here's the final output of the Embed extension in neetoEditor:
1import { Node, mergeAttributes, PasteRule } from "@tiptap/core"; 2import { ReactNodeViewRenderer } from "@tiptap/react"; 3import { TextSelection } from "prosemirror-state"; 4 5import { COMBINED_REGEX } from "common/constants"; 6 7import EmbedComponent from "./EmbedComponent"; 8import { validateUrl } from "./utils"; 9 10export default Node.create({ 11 name: "embed" 12 13 addOptions() { 14 return { inline: false, HTMLAttributes: {} }; 15 }, 16 17 inline() { 18 return this.options.inline; 19 }, 20 21 group() { 22 return this.options.inline ? "inline" : "block"; 23 }, 24 25 addAttributes() { 26 return { 27 src: { default: null }, 28 29 title: { default: null }, 30 31 frameBorder: { default: "0" }, 32 33 allow: { 34 default: 35 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", 36 }, 37 38 allowfullscreen: { default: "allowfullscreen" }, 39 40 figheight: { 41 default: 281, 42 parseHTML: element => element.getAttribute("figheight"), 43 }, 44 45 figwidth: { 46 default: 500, 47 parseHTML: element => element.getAttribute("figwidth"), 48 }, 49 50 align: { 51 default: "center", 52 parseHTML: element => element.getAttribute("align"), 53 }, 54 }; 55 }, 56 57 parseHTML() { 58 return [{ tag: "iframe[src]" }]; 59 }, 60 61 renderHTML({ HTMLAttributes, node }) { 62 const { align, figheight, figwidth } = node.attrs; 63 64 return [ 65 "div", 66 { 67 class: `neeto-editor__video-wrapper neeto-editor__video--${align}`, 68 }, 69 [ 70 "div", 71 { 72 class: "neeto-editor__video-iframe", 73 style: `width: ${figwidth}px; height: ${figheight}px;`, 74 }, 75 [ 76 "iframe", 77 mergeAttributes(this.options.HTMLAttributes, { 78 ...HTMLAttributes, 79 }), 80 ], 81 ], 82 ]; 83 }, 84 85 addNodeView() { 86 return ReactNodeViewRenderer(EmbedComponent); 87 }, 88 89 addCommands() { 90 return { 91 setExternalVideo: 92 options => 93 ({ commands }) => 94 commands.insertContent({ type: this.name, attrs: options }), 95 }; 96 }, 97 98 addPasteRules() { 99 return [ 100 new PasteRule({ 101 find: COMBINED_REGEX, 102 handler: ({ state, range, match }) => { 103 state.tr.delete(range.from, range.to); 104 state.tr.setSelection( 105 TextSelection.create(state.doc, range.from + 1) 106 ); 107 108 const validatedUrl = validateUrl(match[0]); 109 if (validatedUrl) { 110 const node = state.schema.nodes["embed"].create({ 111 src: validatedUrl, 112 }); 113 state.tr.insert(range.from, node); 114 state.tr.insert( 115 range.from + node.nodeSize + 1, 116 state.schema.nodes.paragraph.create() 117 ); 118 119 state.tr.setSelection( 120 TextSelection.create(state.tr.doc, range.from + node.nodeSize + 1) 121 ); 122 } 123 }, 124 }), 125 ]; 126 }, 127});