Skip to content

Commit 0156f91

Browse files
ahtesham-quraishAhtesham Quraish
andauthored
feat: Add Custom Media Embed Plugin for Tiptap Editor (#2711)
add media plugin for embedding the videos --------- Co-authored-by: Ahtesham Quraish <[email protected]>
1 parent 188811f commit 0156f91

File tree

11 files changed

+610
-3
lines changed

11 files changed

+610
-3
lines changed

frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import TiptapEditor, { MainToolbarContent } from "./TiptapEditor"
2727

2828
// --- Tiptap Node ---
2929
import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension"
30+
import { MediaEmbed } from "./components/tiptap-node/media-embed/media-embed-extension"
3031
import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
3132

3233
import "./components/tiptap-node/blockquote-node/blockquote-node.scss"
@@ -60,6 +61,7 @@ const ViewContainer = styled.div({
6061

6162
const Title = styled(Typography)<TypographyProps>({
6263
margin: "60px auto",
64+
maxWidth: "1000px",
6365
})
6466

6567
const TitleInput = styled(Input)({
@@ -152,6 +154,11 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
152154
setTouched(true)
153155
setContent(json)
154156
},
157+
onCreate: ({ editor }) => {
158+
editor.commands.updateAttributes("mediaEmbed", {
159+
editable: !readOnly,
160+
})
161+
},
155162
editorProps: {
156163
attributes: {
157164
autocomplete: "off",
@@ -179,6 +186,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
179186
Subscript,
180187
Selection,
181188
Image,
189+
MediaEmbed,
182190
ImageUploadNode.configure({
183191
accept: "image/*",
184192
maxSize: MAX_FILE_SIZE,
@@ -189,6 +197,25 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
189197
],
190198
})
191199

200+
React.useEffect(() => {
201+
if (!editor) return
202+
203+
editor
204+
.chain()
205+
.command(({ tr, state }) => {
206+
state.doc.descendants((node, pos) => {
207+
if (node.type.name === "mediaEmbed") {
208+
tr.setNodeMarkup(pos, undefined, {
209+
...node.attrs,
210+
editable: !readOnly,
211+
})
212+
}
213+
})
214+
return true
215+
})
216+
.run()
217+
}, [editor, readOnly])
218+
192219
if (!editor) return null
193220

194221
const isPending = isCreating || isUpdating
@@ -212,7 +239,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
212239
</StyledToolbar>
213240
) : (
214241
<StyledToolbar>
215-
<MainToolbarContent />
242+
<MainToolbarContent editor={editor} />
216243
<Button
217244
variant="primary"
218245
disabled={isPending || !title.trim() || !touched}

frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import styled from "@emotion/styled"
77
import { EditorContent } from "@tiptap/react"
88
import type { Editor } from "@tiptap/core"
99
import { ImageUploadButton } from "./components/tiptap-ui/image-upload-button"
10+
import { MediaEmbedButton } from "./components/tiptap-ui/media-embed/media-embed-button"
1011

1112
// --- UI Primitives ---
1213
import { Spacer } from "./components/tiptap-ui-primitive/spacer"
@@ -47,9 +48,12 @@ const StyledEditorContent = styled(EditorContent)<{ readOnly: boolean }>(
4748
backgroundColor: theme.custom.colors.white,
4849
borderRadius: "10px",
4950
margin: "20px auto",
51+
".tiptap.ProseMirror.simple-editor": {
52+
padding: "3rem 3rem 5vh",
53+
},
5054
...(readOnly
5155
? {
52-
maxWidth: "100%",
56+
maxWidth: "1000px",
5357
backgroundColor: "transparent",
5458
".tiptap.ProseMirror.simple-editor": {
5559
padding: "0",
@@ -59,7 +63,10 @@ const StyledEditorContent = styled(EditorContent)<{ readOnly: boolean }>(
5963
}),
6064
)
6165

62-
export const MainToolbarContent = () => {
66+
interface TiptapEditorToolbarProps {
67+
editor: Editor
68+
}
69+
export const MainToolbarContent = ({ editor }: TiptapEditorToolbarProps) => {
6370
return (
6471
<>
6572
<Spacer />
@@ -109,6 +116,9 @@ export const MainToolbarContent = () => {
109116
<ToolbarGroup>
110117
<ImageUploadButton text="Add" />
111118
</ToolbarGroup>
119+
<ToolbarGroup>
120+
<MediaEmbedButton editor={editor} text="Embed" />
121+
</ToolbarGroup>
112122
<Spacer />
113123
</>
114124
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from "react"
2+
export const FullWidth = () => (
3+
<span className="svg-icon">
4+
<svg width="25" height="25">
5+
<path
6+
d="M4.027 17.24V5.492c0-.117.046-.216.14-.3a.453.453 0 01.313-.123h17.007c.117 0 .22.04.313.12.093.08.14.18.14.3v11.74c0 .11-.046.21-.14.3a.469.469 0 01-.313.12H4.48a.432.432 0 01-.314-.13.41.41 0 01-.14-.3zm2.943 3.407v-.833a.45.45 0 01.122-.322.387.387 0 01.276-.132H18.61a.35.35 0 01.27.132.472.472 0 01.116.322v.833c0 .117-.04.216-.116.3a.361.361 0 01-.27.123H7.368a.374.374 0 01-.276-.124.405.405 0 01-.122-.3z"
7+
fillRule="evenodd"
8+
></path>
9+
</svg>
10+
</span>
11+
)
12+
13+
export const WideWidth = () => (
14+
<span className="svg-icon">
15+
<svg width="25" height="25">
16+
<path
17+
d="M3 17.004V9.01a.4.4 0 01.145-.31.476.476 0 01.328-.13h17.74c.12 0 .23.043.327.13a.4.4 0 01.145.31v7.994a.404.404 0 01-.145.313.48.48 0 01-.328.13H3.472a.483.483 0 01-.327-.13.402.402 0 01-.145-.313zm2.212 3.554v-.87c0-.13.05-.243.145-.334a.472.472 0 01.328-.137H19c.124 0 .23.045.322.137a.457.457 0 01.138.335v.86c0 .12-.046.22-.138.31a.478.478 0 01-.32.13H5.684a.514.514 0 01-.328-.13.415.415 0 01-.145-.32zm0-14.246v-.84c0-.132.05-.243.145-.334A.477.477 0 015.685 5H19a.44.44 0 01.322.138.455.455 0 01.138.335v.84a.451.451 0 01-.138.334.446.446 0 01-.32.138H5.684a.466.466 0 01-.328-.138.447.447 0 01-.145-.335z"
18+
fill-rule="evenodd"
19+
></path>
20+
</svg>
21+
</span>
22+
)
23+
24+
export const DefaultWidth = () => (
25+
<span className="svg-icon">
26+
<svg width="25" height="25">
27+
<path
28+
d="M5 20.558v-.9c0-.122.04-.226.122-.312a.404.404 0 01.305-.13h13.347a.45.45 0 01.32.13c.092.086.138.19.138.312v.9a.412.412 0 01-.138.313.435.435 0 01-.32.13H5.427a.39.39 0 01-.305-.13.432.432 0 01-.122-.31zm0-3.554V9.01c0-.12.04-.225.122-.31a.4.4 0 01.305-.13h13.347c.122 0 .23.043.32.13.092.085.138.19.138.31v7.994a.462.462 0 01-.138.328.424.424 0 01-.32.145H5.427a.382.382 0 01-.305-.145.501.501 0 01-.122-.328zM5 6.342v-.87c0-.12.04-.23.122-.327A.382.382 0 015.427 5h13.347c.122 0 .23.048.32.145a.462.462 0 01.138.328v.87c0 .12-.046.225-.138.31a.447.447 0 01-.32.13H5.427a.4.4 0 01-.305-.13.44.44 0 01-.122-.31z"
29+
fill-rule="evenodd"
30+
></path>
31+
</svg>
32+
</span>
33+
)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React, { useRef, useState, useEffect } from "react"
2+
import { NodeViewWrapper } from "@tiptap/react"
3+
import { FullWidth, WideWidth, DefaultWidth } from "./Icons"
4+
5+
import "./style.scss"
6+
7+
interface MediaEmbedNodeProps {
8+
node: any
9+
updateAttributes: (attrs: Record<string, any>) => void
10+
}
11+
12+
export const MediaEmbedNodeView = ({
13+
node,
14+
updateAttributes,
15+
}: MediaEmbedNodeProps) => {
16+
const containerRef = useRef<HTMLDivElement>(null)
17+
const [hover, setHover] = useState(false)
18+
19+
const isEditable = node.attrs.editable
20+
21+
const checkHover = (e: React.MouseEvent) => {
22+
if (isEditable === false) return
23+
if (!containerRef.current) return
24+
25+
const rect = containerRef.current.getBoundingClientRect()
26+
27+
const inside =
28+
e.clientX >= rect.left &&
29+
e.clientX <= rect.right &&
30+
e.clientY >= rect.top &&
31+
e.clientY <= rect.bottom
32+
33+
setHover(inside)
34+
}
35+
return (
36+
<NodeViewWrapper
37+
className={`media-embed ${node.attrs.layout}`}
38+
style={{
39+
float: node.attrs.float || "none",
40+
}}
41+
>
42+
<div
43+
ref={containerRef}
44+
onMouseMove={checkHover}
45+
onMouseLeave={() => {
46+
if (!hover) return
47+
setHover(false)
48+
}}
49+
className="media-container"
50+
>
51+
<div
52+
className="iframe-shield"
53+
style={{ pointerEvents: hover ? "auto" : "none" }}
54+
/>
55+
{isEditable && hover && (
56+
<>
57+
<div className="media-layout-toolbar">
58+
<button
59+
className={node.attrs.layout === "default" ? "active" : ""}
60+
onClick={() => updateAttributes({ layout: "default" })}
61+
title="Default width"
62+
>
63+
<DefaultWidth />
64+
</button>
65+
66+
<button
67+
className={node.attrs.layout === "wide" ? "active" : ""}
68+
onClick={() => updateAttributes({ layout: "wide" })}
69+
title="Wide"
70+
>
71+
<WideWidth />
72+
</button>
73+
74+
<button
75+
className={node.attrs.layout === "full" ? "active" : ""}
76+
onClick={() => updateAttributes({ layout: "full" })}
77+
title="Full width"
78+
>
79+
<FullWidth />
80+
</button>
81+
</div>
82+
</>
83+
)}
84+
85+
{/* Iframe */}
86+
<iframe
87+
src={node.attrs.src}
88+
width={"100%"}
89+
height={"100%"}
90+
style={{ display: "block", borderRadius: "6px" }}
91+
frameBorder={node.attrs.frameborder}
92+
allowFullScreen={node.attrs.allowfullscreen === "true"}
93+
/>
94+
</div>
95+
<div className="media-caption">
96+
{isEditable ? (
97+
<input
98+
type="text"
99+
placeholder="Add caption…"
100+
value={node.attrs.caption || ""}
101+
onChange={(e) => updateAttributes({ caption: e.target.value })}
102+
/>
103+
) : (
104+
node.attrs.caption && <p>{node.attrs.caption}</p>
105+
)}
106+
</div>
107+
</NodeViewWrapper>
108+
)
109+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Node, mergeAttributes, type CommandProps } from "@tiptap/core"
2+
import { ReactNodeViewRenderer } from "@tiptap/react"
3+
import { MediaEmbedNodeView } from "./MediaEmbedNodeView"
4+
5+
declare module "@tiptap/core" {
6+
interface Commands<ReturnType> {
7+
mediaEmbed: {
8+
insertMedia: (src: string) => ReturnType
9+
}
10+
}
11+
}
12+
13+
export const MediaEmbed = Node.create({
14+
name: "mediaEmbed",
15+
16+
group: "block",
17+
atom: true,
18+
19+
addAttributes() {
20+
return {
21+
src: { default: null },
22+
width: { default: "100%" },
23+
height: { default: "100%" },
24+
frameborder: { default: 0 },
25+
allowfullscreen: { default: "true" },
26+
float: { default: null }, // ← NEW ("left" | "right" | null)
27+
editable: { default: true },
28+
layout: {
29+
default: "default", // 👈 NEW!
30+
},
31+
caption: {
32+
default: "",
33+
parseHTML: (element) => element.getAttribute("data-caption") || "",
34+
renderHTML: (attrs) => ({
35+
"data-caption": attrs.caption,
36+
}),
37+
},
38+
}
39+
},
40+
41+
parseHTML() {
42+
return [{ tag: "iframe[src]" }]
43+
},
44+
45+
renderHTML({ HTMLAttributes }) {
46+
return ["iframe", mergeAttributes(HTMLAttributes)]
47+
},
48+
49+
addCommands() {
50+
return {
51+
insertMedia:
52+
(src: string) =>
53+
({ commands }: CommandProps) => {
54+
return commands.insertContent({
55+
type: this.name,
56+
attrs: { src },
57+
})
58+
},
59+
}
60+
},
61+
addNodeView() {
62+
return ReactNodeViewRenderer(MediaEmbedNodeView)
63+
},
64+
})

0 commit comments

Comments
 (0)