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
7 changes: 7 additions & 0 deletions .changeset/x-post-media-click.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@meowdown/embed': minor
'@meowdown/core': minor
'@meowdown/react': minor
---

Add `onXPostMediaClick` for photos and videos in X post cards; card videos now start from a poster button.
2 changes: 2 additions & 0 deletions packages/core/src/extensions/editor-config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import type { FollowLinkHandlers } from './follow-link.ts'
import type { ImageOptions } from './image.ts'
import type { InlineMarkOptions } from './inline-text-to-mark-chunks.ts'
import type { MarkMode } from './mark-mode.ts'
import type { XPostMediaClickHandler } from './x-post-media-click.ts'

export interface EditorConfig
extends InlineMarkOptions, FollowLinkHandlers, FilePasteOptions, FileViewOptions, ImageOptions {
markMode?: MarkMode
onExitBoundary?: ExitBoundaryHandler
onXPostMediaClick?: XPostMediaClickHandler
embedPaste?: boolean
linkPaste?: boolean
bulletAfterHeading?: boolean
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/extensions/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { defineViewAttributes } from './view-attributes.ts'
import { defineWikilinkClickHandler } from './wikilink-click.ts'
import { defineWikilinkTrigger } from './wikilink-trigger.ts'
import { defineWikilink } from './wikilink.ts'
import { defineXPostMediaClickHandler } from './x-post-media-click.ts'

function defineEditorExtensionImpl(options: EditorExtensionOptions) {
return union(
Expand All @@ -91,6 +92,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) {
defineModClickPrevention(),
defineFileClickHandler((state) => getEditorConfig(state).onFileClick),
defineImageClickHandler((state) => getEditorConfig(state).onImageClick),
defineXPostMediaClickHandler((state) => getEditorConfig(state).onXPostMediaClick),
defineWikilinkClickHandler((state) => getEditorConfig(state).onWikilinkClick),
defineTagClickHandler((state) => getEditorConfig(state).onTagClick),
defineLinkClickHandler((state) => getEditorConfig(state).onLinkClick),
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/extensions/post-embed-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,57 @@ describe('post embed clicks', () => {
})
})

describe('X post media clicks', () => {
// A photo that loads without the network: the card hides one that fails.
const PHOTO_URL =
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'/%3E"

function createMediaPost(): XPost {
const post = createXPost()
post.media = [
{ type: 'photo', url: PHOTO_URL, width: 100, height: 100 },
{
type: 'video',
width: 100,
height: 100,
sources: [{ type: 'video/mp4', url: 'https://example.com/video.mp4' }],
},
]
return post
}

it('reports a clicked photo and video instead of running the card default', async () => {
const onXPostMediaClick = vi.fn()
using fixture = setupFixture({
extensionOptions: {
resolveXPost: createMediaPost,
mediaUrlProtocols: ['data:'],
onXPostMediaClick,
},
})
fixture.set(fixture.n.doc(fixture.n.paragraph(TWEET)))
const image = xPostCard.locate('[data-media] img')
await expect.element(image).toBeInTheDocument()
await userEvent.click(image)
await userEvent.click(xPostCard.getByRole('button', { name: 'Play video' }))
expect(onXPostMediaClick).toHaveBeenCalledTimes(2)
expect(onXPostMediaClick.mock.calls[0][0]).toMatchObject({
index: 0,
media: { type: 'photo', url: PHOTO_URL },
element: image.element(),
})
expect(onXPostMediaClick.mock.calls[1][0]).toMatchObject({ index: 1 })
expect(xPostCard.element().querySelector('video')).toBeNull()
})

it('keeps the card default without a handler', async () => {
using fixture = setupFixture({ extensionOptions: { resolveXPost: createMediaPost } })
fixture.set(fixture.n.doc(fixture.n.paragraph(TWEET)))
await userEvent.click(xPostCard.getByRole('button', { name: 'Play video' }))
await expect.element(xPostCard.locate('video')).toBeInTheDocument()
})
})

describe('X post embed', () => {
it('passes separate resolver and media protocol options to X cards', async () => {
const post = createXPost()
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/extensions/x-post-media-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { XPostMediaClickDetail } from '@meowdown/embed/x'
import { definePlugin, type PlainExtension } from '@prosekit/core'
import { Plugin, PluginKey, type EditorState } from '@prosekit/pm/state'

const xPostMediaClickKey = new PluginKey('meowdown-x-post-media-click')

/**
* Payload for {@link XPostMediaClickHandler}: the activated photo or video of
* an X post card, its sibling items, and the rendered thumbnail element.
*/
export type XPostMediaClickPayload = XPostMediaClickDetail

export type XPostMediaClickHandler = (payload: XPostMediaClickPayload) => void

/**
* Call `onClick` when the user activates a photo or video inside an X post
* card. With a handler the card's own default (open the photo URL, play the
* video in place) is cancelled, so the host can show the media itself.
*/
export function defineXPostMediaClickHandler(
getOnClick?: (state: EditorState) => XPostMediaClickHandler | undefined,
): PlainExtension {
return definePlugin(
new Plugin({
key: xPostMediaClickKey,
props: {
handleDOMEvents: {
'meowdown-embed-media-click': (view, event) => {
const handler = getOnClick?.(view.state)
if (!handler) return false
event.preventDefault()
handler(event.detail)
return true
},
},
},
}),
)
}
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,8 @@ export { loadKaTeX, renderMathInto, type KaTeXRender } from './utils/katex.ts'
export type { PositionRange } from './utils/range.ts'
export { getSelectedText } from './utils/selected-text.ts'
export { getVirtualElementFromRange, type VirtualElement } from './utils/virtual-element.ts'
export {
defineXPostMediaClickHandler,
type XPostMediaClickHandler,
type XPostMediaClickPayload,
} from './extensions/x-post-media-click.ts'
32 changes: 27 additions & 5 deletions packages/embed/src/x/features.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import './theme.css'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { page } from 'vitest/browser'

import type { XPostMediaClickDetail } from './media-click.ts'
import { createPhoto, createPost, createVideo } from './testing/fixtures.ts'

import { registerXPost } from './index.ts'
Expand Down Expand Up @@ -49,21 +50,20 @@ describe('Full post snapshots', () => {
expect(box.height).toBeCloseTo((box.width * 400) / 640, 0)
})

it('uses MP4 before HLS, native controls, and opt-in GIF playback', async () => {
it('plays a video in place after a click on its poster', async () => {
const snapshot = createPost()
snapshot.media = [createVideo(), createVideo(true)]
const element = mount(snapshot)
await expect.element(post.getByText('Hello 😀', { exact: false })).toBeVisible()
expect(element.querySelector('video')).toBeNull()
await post.getByRole('button', { name: 'Play video' }).click()
await post.getByRole('button', { name: 'Play GIF' }).click()
const videos = element.querySelectorAll('video')
expect(videos).toHaveLength(2)
expect(videos[0].querySelector('source')?.src).toBe('https://example.com/high.mp4')
expect(videos[0].controls).toBe(true)
expect(videos[0].preload).toBe('none')
expect(videos[0].autoplay).toBe(false)
expect(videos[0].loop).toBe(false)
expect(videos[1].loop).toBe(true)
expect(videos[1].muted).toBe(true)
expect(videos[1].autoplay).toBe(false)
const pause = vi.spyOn(videos[0], 'pause')
element.remove()
expect(pause).toHaveBeenCalled()
Expand Down Expand Up @@ -153,10 +153,32 @@ describe('Full post snapshots', () => {
]
const element = mount(snapshot)
expect(element.querySelectorAll('[data-media-item]')).toHaveLength(2)
await post.getByRole('button', { name: 'Play video' }).click()
element.querySelector('source')!.dispatchEvent(new Event('error'))
await expect
.element(post.getByText('Media could not be loaded.', { exact: false }).nth(1))
.toBeVisible()
expect(element.querySelector('video')?.hidden).toBe(true)
})

it('lets a host take over a media click', async () => {
const snapshot = createPost()
snapshot.media = [createPhoto(), createVideo()]
const element = mount(snapshot)
const details: XPostMediaClickDetail[] = []
element.addEventListener('meowdown-embed-media-click', (event) => {
event.preventDefault()
details.push(event.detail)
})
await post.getByRole('img', { name: 'Blue illustrated mountains' }).click()
await post.getByRole('button', { name: 'Play video' }).click()
expect(element.querySelector('video')).toBeNull()
expect(details.map((detail) => detail.index)).toEqual([0, 1])
expect(details[0].element).toBe(element.querySelector('[data-media] img'))
expect(details[1].items).toHaveLength(2)
expect(details[1].media).toMatchObject({
type: 'video',
sources: [{ url: 'https://example.com/high.mp4' }, {}, {}],
})
})
})
6 changes: 6 additions & 0 deletions packages/embed/src/x/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { XPostMediaClickEvent } from './media-click.ts'
import type { XPostElement } from './x-post.ts'

export type { Resolver } from '../fetch.ts'
export { X_POST_MEDIA_CLICK } from './media-click.ts'
export type { XPostMediaClickDetail, XPostMediaClickEvent } from './media-click.ts'
export { registerXPost } from './register.ts'
export { useXPost } from './x-post.ts'
export type { XPostElement, XPostProps } from './x-post.ts'
Expand All @@ -9,4 +12,7 @@ declare global {
interface HTMLElementTagNameMap {
'meowdown-embed-x': XPostElement
}
interface HTMLElementEventMap {
'meowdown-embed-media-click': XPostMediaClickEvent
}
}
41 changes: 41 additions & 0 deletions packages/embed/src/x/media-click.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { XPostMedia } from '@post-embed/types'

export const X_POST_MEDIA_CLICK = 'meowdown-embed-media-click'

export interface XPostMediaClickDetail {
/**
* The activated item. Its URLs already passed the `mediaUrlProtocols` check,
* and video sources are sorted best first.
*/
media: XPostMedia
/**
* Every displayable item of the same post, in order, for paging.
*/
items: XPostMedia[]
/**
* Position of `media` in `items`.
*/
index: number
/**
* The rendered thumbnail: the photo `<img>`, or the video poster.
*/
element: HTMLElement
permalink?: string | undefined
}

export type XPostMediaClickEvent = CustomEvent<XPostMediaClickDetail>

/**
* Returns false when a listener called `preventDefault()`: the host shows the
* media itself, so the card must not run its own default.
*/
export function dispatchMediaClick(target: HTMLElement, detail: XPostMediaClickDetail): boolean {
return target.dispatchEvent(
new CustomEvent(X_POST_MEDIA_CLICK, {
detail,
bubbles: true,
cancelable: true,
composed: true,
}),
)
}
Loading
Loading