Skip to content
Open
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
2 changes: 2 additions & 0 deletions frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tiptap/static-renderer": "^3.13.0",
"@types/video.js": "^7.3.58",
"api": "workspace:*",
"classnames": "^2.5.1",
"formik": "^2.4.6",
Expand All @@ -62,6 +63,7 @@
"sharp": "0.34.4",
"slick-carousel": "^1.8.1",
"tiny-invariant": "^1.3.3",
"video.js": "8.23.7",
"yup": "^1.4.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from "react"
import styled from "@emotion/styled"
import { isVideoUrl } from "./lib"
import { VideoJsPlayer } from "./VideoJsPlayer"

const MediaContainer = styled.div(({ theme }) => ({
position: "relative",
width: "100%",
aspectRatio: "16 / 9",
overflow: "hidden",

iframe: {
width: "100%",
height: "100%",
borderRadius: "6px",
display: "block",
},

video: {
width: "100%",
height: "100%",
borderRadius: "6px",
display: "block",
objectFit: "contain",
backgroundColor: "#000",
},

// Video.js player styling
".video-js": {
width: "100%",
height: "100%",
borderRadius: "6px",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty minor comment, but I'm not sure if any of this .video-js css is necessary/doing anything.

The height is being overridden to 0px, then videojs does some padding shenanigans to make it look good. And, perhaps related to that, the border radius isn't doing anything:

Image

And videojs already sets width: 100%

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the border radius being applied if you see and I am not following your point sir
Screenshot 2026-02-27 at 2 38 55 PM

},

".layout-full & iframe, .layout-full & video, .layout-full & .video-js": {
borderRadius: 0,
},
".ProseMirror-selectednode .layout-wide &": {
border: `1px solid ${theme.custom.colors.red}`,
padding: "8px",
borderRadius: "10px",
},
".ProseMirror-selectednode .layout-full &": {
border: `1px solid ${theme.custom.colors.red}`,
padding: "8px 0",
borderWidth: "1px 0",
},
}))

interface MediaDisplayProps {
src: string
caption?: string
}

export const MediaDisplay = ({ src, caption }: MediaDisplayProps) => {
return (
<MediaContainer>
{isVideoUrl(src) ? (
<VideoJsPlayer src={src} caption={caption} />
) : (
<iframe
src={src}
frameBorder="0"
allowFullScreen
title={caption}
inert={true}
/>
)}
</MediaContainer>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FullWidth, WideWidth, DefaultWidth } from "./Icons"
import { RiCloseLargeLine } from "@remixicon/react"
import { ActionButton } from "@mitodl/smoot-design"
import { EditableCaption } from "../shared/EditableCaption"
import { MediaDisplay } from "./MediaDisplay"

const StyledNodeViewWrapper = styled(NodeViewWrapper, {
shouldForwardProp: (prop) =>
Expand Down Expand Up @@ -110,34 +111,6 @@ const MediaLayoutToolbar = styled.div({
},
})

const MediaContainer = styled.div(({ theme }) => ({
position: "relative",
width: "100%",
aspectRatio: "16 / 9",
overflow: "hidden",

iframe: {
width: "100%",
height: "100%",
borderRadius: "6px",
display: "block",
},

".layout-full & iframe": {
borderRadius: 0,
},
".ProseMirror-selectednode .layout-wide &": {
border: `1px solid ${theme.custom.colors.red}`,
padding: "8px",
borderRadius: "10px",
},
".ProseMirror-selectednode .layout-full &": {
border: `1px solid ${theme.custom.colors.red}`,
padding: "8px 0",
borderWidth: "1px 0",
},
}))

interface MediaEmbedNodeProps {
node: NodeViewProps["node"]
editor: NodeViewProps["editor"]
Expand Down Expand Up @@ -215,15 +188,7 @@ export const MediaEmbedNodeView = ({
</MediaLayoutToolbar>
)}

<MediaContainer>
<iframe
src={src}
frameBorder="0"
allowFullScreen
title={caption}
inert={editable}
/>
</MediaContainer>
<MediaDisplay src={src} caption={caption} />
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The refactored MediaDisplay component no longer applies the inert attribute, making embedded iframes interactive in editor mode, which was previously prevented.
Severity: MEDIUM

Suggested Fix

Add an editable boolean prop to the MediaDisplay component. Pass the editable state from MediaEmbedNodeView.tsx to MediaDisplay. Inside MediaDisplay, conditionally apply the inert={editable} attribute to the iframe element to restore the intended non-interactive behavior during editing.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
frontends/main/src/page-components/TiptapEditor/extensions/node/MediaEmbed/MediaEmbedNodeView.tsx#L191

Potential issue: The refactoring of media embed rendering into the `MediaDisplay`
component resulted in the loss of the `inert={editable}` attribute on iframes.
Previously, this attribute made embedded media non-interactive when the editor was in an
editable state. Now, because the `MediaDisplay` component is not aware of the editor's
`editable` status, embedded iframes (e.g., YouTube, Vimeo) are interactive during
editing. This is a functional regression that allows unintended user interaction with
embeds while editing content.

Did we get this right? 👍 / 👎 to inform future reviews.


<EditableCaption
caption={caption}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react"
import styled from "@emotion/styled"
import { EditableCaption } from "../shared/EditableCaption"
import { MediaDisplay } from "./MediaDisplay"

const StyledWrapper = styled.div({
position: "relative",
Expand All @@ -23,20 +24,6 @@ const StyledWrapper = styled.div({
},
})

const MediaContainer = styled.div({
position: "relative",
width: "100%",
aspectRatio: "16 / 9",
overflow: "hidden",

iframe: {
width: "100%",
height: "100%",
borderRadius: "6px",
display: "block",
},
})

interface MediaEmbedNode {
attrs: {
src?: string
Expand All @@ -52,9 +39,7 @@ export const MediaEmbedViewer = ({ node }: { node?: MediaEmbedNode }) => {

return (
<StyledWrapper className={`layout-${layout}`}>
<MediaContainer>
<iframe src={src} frameBorder="0" allowFullScreen title={caption} />
</MediaContainer>
<MediaDisplay src={src} caption={caption} />

<EditableCaption
caption={caption}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React from "react"
import { render, screen } from "@testing-library/react"

// Mock video.js with all functionality inline
jest.mock("video.js", () => {
const mockPlayer = {
dispose: jest.fn(),
on: jest.fn(),
isDisposed: jest.fn(() => false),
error: jest.fn(),
ready: jest.fn((callback) => {
// Call the callback immediately in tests
callback()
}),
addRemoteTextTrack: jest.fn(),
textTracks: jest.fn(() => ({
length: 0,
tracks_: [],
})),
}

const mockFn = jest.fn(() => mockPlayer)

return Object.assign(mockFn, {
browser: {
IS_SAFARI: false,
},
})
})

jest.mock("video.js/dist/video-js.css", () => ({}))

// Import component and videojs
import { VideoJsPlayer } from "./VideoJsPlayer"
import videojs from "video.js"

// Type the mocked videojs
const mockedVideojs = videojs as jest.MockedFunction<typeof videojs>

describe("VideoJsPlayer", () => {
const defaultProps = {
src: "https://example.cloudfront.net/video.m3u8",
caption: "Test Video",
}

beforeEach(() => {
jest.clearAllMocks()
})

it("renders a video player container", () => {
render(<VideoJsPlayer {...defaultProps} />)
const container = screen.getByTitle(defaultProps.caption)
expect(container).toBeInTheDocument()
})

it("initializes video.js player with correct options", () => {
render(<VideoJsPlayer {...defaultProps} />)

expect(mockedVideojs).toHaveBeenCalledWith(
expect.any(HTMLElement),
expect.objectContaining({
controls: true,
responsive: true,
fluid: true,
preload: "auto",
}),
)
})

it("sets HLS source correctly", () => {
render(<VideoJsPlayer {...defaultProps} />)

const [[, options]] = mockedVideojs.mock.calls
expect(options.sources).toEqual([
{
src: defaultProps.src,
type: "application/x-mpegURL",
},
])
})

it("registers error handler", () => {
render(<VideoJsPlayer {...defaultProps} />)

const mockPlayer = mockedVideojs.mock.results[0].value
expect(mockPlayer.on).toHaveBeenCalledWith("error", expect.any(Function))
})

it("handles player errors by logging", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation()
const error = { code: 4, message: "Network error" }

render(<VideoJsPlayer {...defaultProps} />)

const mockPlayer = mockedVideojs.mock.results[0].value
mockPlayer.error.mockReturnValue(error)

// Trigger the error handler
const errorHandler = mockPlayer.on.mock.calls.find(
([event]: [string]) => event === "error",
)?.[1]
errorHandler?.()

expect(consoleSpy).toHaveBeenCalledWith("Video.js error:", error)
consoleSpy.mockRestore()
})

it("disposes player on unmount", () => {
const { unmount } = render(<VideoJsPlayer {...defaultProps} />)

const mockPlayer = mockedVideojs.mock.results[0].value

unmount()

expect(mockPlayer.isDisposed).toHaveBeenCalled()
expect(mockPlayer.dispose).toHaveBeenCalled()
})

it("does not dispose if already disposed", () => {
const { unmount } = render(<VideoJsPlayer {...defaultProps} />)

const mockPlayer = mockedVideojs.mock.results[0].value
mockPlayer.isDisposed.mockReturnValue(true)

unmount()

expect(mockPlayer.isDisposed).toHaveBeenCalled()
expect(mockPlayer.dispose).not.toHaveBeenCalled()
})

it("initializes player only once", () => {
const { rerender } = render(<VideoJsPlayer {...defaultProps} />)

expect(mockedVideojs).toHaveBeenCalledTimes(1)

rerender(<VideoJsPlayer {...defaultProps} />)

expect(mockedVideojs).toHaveBeenCalledTimes(1)
})

it("does not reinitialize when src changes", () => {
const { rerender } = render(<VideoJsPlayer {...defaultProps} />)

expect(mockedVideojs).toHaveBeenCalledTimes(1)

const newSrc = "https://example.cloudfront.net/new-video.m3u8"
rerender(<VideoJsPlayer src={newSrc} caption={defaultProps.caption} />)

// Player is not reinitialized when src changes because playerRef.current exists
// To change video source, the player would need to be updated via player.src()
expect(mockedVideojs).toHaveBeenCalledTimes(1)
})

it("applies correct CSS classes to video element", () => {
render(<VideoJsPlayer {...defaultProps} />)

const videoElement = mockedVideojs.mock.calls[0][0] as HTMLElement
expect(videoElement.classList.contains("vjs-big-play-centered")).toBe(true)
})

it("sets container with data-vjs-player attribute", () => {
render(<VideoJsPlayer {...defaultProps} />)

const titleElement = screen.getByTitle(defaultProps.caption)
const playerContainer = titleElement.parentElement
expect(playerContainer).toHaveAttribute("data-vjs-player")
expect(playerContainer).toHaveStyle({ width: "100%", height: "100%" })
})
})
Loading
Loading