Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import TiptapEditor, { MainToolbarContent } from "./TiptapEditor"

// --- Tiptap Node ---
import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension"
import { MediaEmbed } from "./components/tiptap-node/media-embed/media-embed-extension"
import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"

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

const Title = styled(Typography)<TypographyProps>({
margin: "60px auto",
maxWidth: "1000px",
})

const TitleInput = styled(Input)({
Expand Down Expand Up @@ -152,6 +154,11 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
setTouched(true)
setContent(json)
},
onCreate: ({ editor }) => {
editor.commands.updateAttributes("mediaEmbed", {
editable: !readOnly,
})
},
editorProps: {
attributes: {
autocomplete: "off",
Expand Down Expand Up @@ -179,6 +186,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
Subscript,
Selection,
Image,
MediaEmbed,
ImageUploadNode.configure({
accept: "image/*",
maxSize: MAX_FILE_SIZE,
Expand All @@ -189,6 +197,25 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
],
})

React.useEffect(() => {
if (!editor) return

editor
.chain()
.command(({ tr, state }) => {
state.doc.descendants((node, pos) => {
if (node.type.name === "mediaEmbed") {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
editable: !readOnly,
})
}
})
return true
})
.run()
}, [editor, readOnly])

if (!editor) return null

const isPending = isCreating || isUpdating
Expand All @@ -212,7 +239,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
</StyledToolbar>
) : (
<StyledToolbar>
<MainToolbarContent />
<MainToolbarContent editor={editor} />
<Button
variant="primary"
disabled={isPending || !title.trim() || !touched}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import styled from "@emotion/styled"
import { EditorContent } from "@tiptap/react"
import type { Editor } from "@tiptap/core"
import { ImageUploadButton } from "./components/tiptap-ui/image-upload-button"
import { MediaEmbedButton } from "./components/tiptap-ui/media-embed/media-embed-button"

// --- UI Primitives ---
import { Spacer } from "./components/tiptap-ui-primitive/spacer"
Expand Down Expand Up @@ -47,9 +48,12 @@ const StyledEditorContent = styled(EditorContent)<{ readOnly: boolean }>(
backgroundColor: theme.custom.colors.white,
borderRadius: "10px",
margin: "20px auto",
".tiptap.ProseMirror.simple-editor": {
padding: "3rem 3rem 5vh",
},
...(readOnly
? {
maxWidth: "100%",
maxWidth: "1000px",
backgroundColor: "transparent",
".tiptap.ProseMirror.simple-editor": {
padding: "0",
Expand All @@ -59,7 +63,10 @@ const StyledEditorContent = styled(EditorContent)<{ readOnly: boolean }>(
}),
)

export const MainToolbarContent = () => {
interface TiptapEditorToolbarProps {
editor: Editor
}
export const MainToolbarContent = ({ editor }: TiptapEditorToolbarProps) => {
return (
<>
<Spacer />
Expand Down Expand Up @@ -109,6 +116,9 @@ export const MainToolbarContent = () => {
<ToolbarGroup>
<ImageUploadButton text="Add" />
</ToolbarGroup>
<ToolbarGroup>
<MediaEmbedButton editor={editor} text="Embed" />
</ToolbarGroup>
<Spacer />
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from "react"
export const FullWidth = () => (
<span className="svg-icon">
<svg width="25" height="25">
<path
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"
fillRule="evenodd"
></path>
</svg>
</span>
)

export const WideWidth = () => (
<span className="svg-icon">
<svg width="25" height="25">
<path
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"
fill-rule="evenodd"
></path>
</svg>
</span>
)

export const DefaultWidth = () => (
<span className="svg-icon">
<svg width="25" height="25">
<path
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"
fill-rule="evenodd"
></path>
</svg>
</span>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useRef, useState, useEffect } from "react"
import { NodeViewWrapper } from "@tiptap/react"
import { FullWidth, WideWidth, DefaultWidth } from "./Icons"

import "./style.scss"

interface MediaEmbedNodeProps {
node: any
updateAttributes: (attrs: Record<string, any>) => void
}

export const MediaEmbedNodeView = ({
node,
updateAttributes,
}: MediaEmbedNodeProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const [hover, setHover] = useState(false)

const isEditable = node.attrs.editable

const checkHover = (e: React.MouseEvent) => {
if (isEditable === false) return
if (!containerRef.current) return

const rect = containerRef.current.getBoundingClientRect()

const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom

setHover(inside)
}
return (
<NodeViewWrapper
className={`media-embed ${node.attrs.layout}`}
style={{
float: node.attrs.float || "none",
}}
>
<div
ref={containerRef}
onMouseMove={checkHover}
onMouseLeave={() => {
if (!hover) return
setHover(false)
}}
className="media-container"
>
<div
className="iframe-shield"
style={{ pointerEvents: hover ? "auto" : "none" }}
/>
{isEditable && hover && (
<>
<div className="media-layout-toolbar">
<button
className={node.attrs.layout === "default" ? "active" : ""}
onClick={() => updateAttributes({ layout: "default" })}
title="Default width"
>
<DefaultWidth />
</button>

<button
className={node.attrs.layout === "wide" ? "active" : ""}
onClick={() => updateAttributes({ layout: "wide" })}
title="Wide"
>
<WideWidth />
</button>

<button
className={node.attrs.layout === "full" ? "active" : ""}
onClick={() => updateAttributes({ layout: "full" })}
title="Full width"
>
<FullWidth />
</button>
</div>
</>
)}

{/* Iframe */}
<iframe
src={node.attrs.src}
width={"100%"}
height={"100%"}
style={{ display: "block", borderRadius: "6px" }}
frameBorder={node.attrs.frameborder}
allowFullScreen={node.attrs.allowfullscreen === "true"}
/>
</div>
<div className="media-caption">
{isEditable ? (
<input
type="text"
placeholder="Add caption…"
value={node.attrs.caption || ""}
onChange={(e) => updateAttributes({ caption: e.target.value })}
/>
) : (
node.attrs.caption && <p>{node.attrs.caption}</p>
)}
</div>
</NodeViewWrapper>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Node, mergeAttributes, type CommandProps } from "@tiptap/core"
import { ReactNodeViewRenderer } from "@tiptap/react"
import { MediaEmbedNodeView } from "./MediaEmbedNodeView"

declare module "@tiptap/core" {
interface Commands<ReturnType> {
mediaEmbed: {
insertMedia: (src: string) => ReturnType
}
}
}

export const MediaEmbed = Node.create({
name: "mediaEmbed",

group: "block",
atom: true,

addAttributes() {
return {
src: { default: null },
width: { default: "100%" },
height: { default: "100%" },
frameborder: { default: 0 },
allowfullscreen: { default: "true" },
float: { default: null }, // ← NEW ("left" | "right" | null)
editable: { default: true },
layout: {
default: "default", // 👈 NEW!
},
caption: {
default: "",
parseHTML: (element) => element.getAttribute("data-caption") || "",
renderHTML: (attrs) => ({
"data-caption": attrs.caption,
}),
},
}
},

parseHTML() {
return [{ tag: "iframe[src]" }]
},

renderHTML({ HTMLAttributes }) {
return ["iframe", mergeAttributes(HTMLAttributes)]
},

addCommands() {
return {
insertMedia:
(src: string) =>
({ commands }: CommandProps) => {
return commands.insertContent({
type: this.name,
attrs: { src },
})
},
}
},
addNodeView() {
return ReactNodeViewRenderer(MediaEmbedNodeView)
},
})
Loading
Loading