diff --git a/.changeset/x-post-media-click.md b/.changeset/x-post-media-click.md new file mode 100644 index 00000000..52450380 --- /dev/null +++ b/.changeset/x-post-media-click.md @@ -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. diff --git a/packages/core/src/extensions/editor-config-types.ts b/packages/core/src/extensions/editor-config-types.ts index cd73ab76..09a5abad 100644 --- a/packages/core/src/extensions/editor-config-types.ts +++ b/packages/core/src/extensions/editor-config-types.ts @@ -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 diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 3e65427a..1b4c3399 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -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( @@ -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), diff --git a/packages/core/src/extensions/post-embed-view.test.tsx b/packages/core/src/extensions/post-embed-view.test.tsx index 8f50eb8b..42a625bf 100644 --- a/packages/core/src/extensions/post-embed-view.test.tsx +++ b/packages/core/src/extensions/post-embed-view.test.tsx @@ -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() diff --git a/packages/core/src/extensions/x-post-media-click.ts b/packages/core/src/extensions/x-post-media-click.ts new file mode 100644 index 00000000..89060fd3 --- /dev/null +++ b/packages/core/src/extensions/x-post-media-click.ts @@ -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 + }, + }, + }, + }), + ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e64226b3..35669b60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/embed/src/x/features.test.tsx b/packages/embed/src/x/features.test.tsx index b6c8d30e..410e1296 100644 --- a/packages/embed/src/x/features.test.tsx +++ b/packages/embed/src/x/features.test.tsx @@ -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' @@ -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() @@ -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' }, {}, {}], + }) + }) }) diff --git a/packages/embed/src/x/index.ts b/packages/embed/src/x/index.ts index 2e4263ab..a3f7c76b 100644 --- a/packages/embed/src/x/index.ts +++ b/packages/embed/src/x/index.ts @@ -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' @@ -9,4 +12,7 @@ declare global { interface HTMLElementTagNameMap { 'meowdown-embed-x': XPostElement } + interface HTMLElementEventMap { + 'meowdown-embed-media-click': XPostMediaClickEvent + } } diff --git a/packages/embed/src/x/media-click.ts b/packages/embed/src/x/media-click.ts new file mode 100644 index 00000000..2755a043 --- /dev/null +++ b/packages/embed/src/x/media-click.ts @@ -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 ``, or the video poster. + */ + element: HTMLElement + permalink?: string | undefined +} + +export type XPostMediaClickEvent = CustomEvent + +/** + * 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, + }), + ) +} diff --git a/packages/embed/src/x/render-media.ts b/packages/embed/src/x/render-media.ts index 2a3078f7..05fd7ad0 100644 --- a/packages/embed/src/x/render-media.ts +++ b/packages/embed/src/x/render-media.ts @@ -4,6 +4,11 @@ import el from 'crelt' import { renderLink } from '../render-link.ts' import { getSafeUrl } from '../safe-url.ts' +import { dispatchMediaClick } from './media-click.ts' + +type Photo = Extract +type Video = Extract + function dimension(value: number): number | undefined { return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined } @@ -25,6 +30,35 @@ function getOrientation(media: { width: number; height: number }) { return height > width * 1.1 ? 'portrait' : width > height * 1.1 ? 'landscape' : 'square' } +/** + * The item with only the URLs the card may load, or nothing when it has none + * left to show. + */ +function getDisplayable( + media: XPostMedia, + protocols: readonly string[] | null, +): XPostMedia | undefined { + if (media.unavailable) return + if (media.type === 'photo') { + const url = getSafeUrl(media.url, protocols) + return url ? { ...media, url } : undefined + } + const sources = media.sources + .flatMap((source) => { + const url = getSafeUrl(source.url, protocols) + return url ? [{ ...source, url }] : [] + }) + .sort((a, b) => { + return ( + Number(b.type === 'video/mp4') - Number(a.type === 'video/mp4') || + (b.bitrate || 0) - (a.bitrate || 0) + ) + }) + if (sources.length === 0) return + const poster = media.poster && getSafeUrl(media.poster, protocols) + return { ...media, sources, poster } +} + function renderUnavailable(permalink?: string) { return el( 'div', @@ -34,87 +68,119 @@ function renderUnavailable(permalink?: string) { ) } -function renderItem(media: XPostMedia, protocols: readonly string[] | null, permalink?: string) { - const error = el( +function renderError(permalink?: string) { + return el( 'div', { 'data-media-error': '', hidden: true }, 'Media could not be loaded. ', renderLink('View on X', permalink), ) - let content: HTMLAnchorElement | HTMLVideoElement - if (media.type === 'photo') { - const url = getSafeUrl(media.url, protocols) - if (media.unavailable || !url) return renderUnavailable(permalink) - const image = el('img', { - src: url, - alt: media.alt || 'Post image', +} + +function isPlainClick(event: MouseEvent): boolean { + return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey +} + +function renderPhoto(media: Photo, error: HTMLElement, onClick: (element: HTMLElement) => boolean) { + const image = el('img', { + src: media.url, + alt: media.alt || 'Post image', + ...sizeAttrs(media), + loading: 'lazy', + decoding: 'async', + referrerpolicy: 'no-referrer', + }) + const link = el('a', { href: media.url, target: '_blank', rel: 'noopener noreferrer' }, image) + image.addEventListener('error', () => { + link.hidden = true + error.hidden = false + }) + // A modified click keeps the browser's own "open in new tab" behavior. + link.addEventListener('click', (event) => { + if (isPlainClick(event) && !onClick(image)) event.preventDefault() + }) + return link +} + +function renderPlayer(media: Video, error: HTMLElement, permalink?: string) { + const gif = media.type === 'gif' + const sources = media.sources.map((source) => { + return el('source', { src: source.url, type: source.type }) + }) + const video = el( + 'video', + { + controls: true, + playsInline: true, + 'aria-label': gif ? 'Animated GIF' : 'Post video', + poster: media.poster, ...sizeAttrs(media), - loading: 'lazy', - decoding: 'async', - referrerpolicy: 'no-referrer', - }) - const link = el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, image) - image.addEventListener('error', () => { - link.hidden = true - error.hidden = false - }) - content = link - } else { - const sources = media.sources - .flatMap((source) => { - const url = getSafeUrl(source.url, protocols) - return url ? [{ ...source, url }] : [] - }) - .sort((a, b) => { - return ( - Number(b.type === 'video/mp4') - Number(a.type === 'video/mp4') || - (b.bitrate || 0) - (a.bitrate || 0) - ) - }) - .map((source) => el('source', { src: source.url, type: source.type })) - if (media.unavailable || sources.length === 0) { - return renderUnavailable(permalink) - } - const gif = media.type === 'gif' - const video = el( - 'video', - { - controls: true, - playsInline: true, - preload: 'none', - 'aria-label': gif ? 'Animated GIF' : 'Post video', - poster: media.poster && getSafeUrl(media.poster, protocols), - ...sizeAttrs(media), - loop: gif, + loop: gif, + }, + sources, + renderLink('Watch on X', permalink), + ) + video.muted = gif + const showError = () => { + video.hidden = true + error.hidden = false + } + video.addEventListener('error', showError) + let failedSources = 0 + for (const source of sources) { + source.addEventListener( + 'error', + () => { + failedSources++ + if (failedSources === sources.length) showError() }, - sources, - renderLink('Watch on X', permalink), + { once: true }, ) - video.muted = gif - const showError = () => { - video.hidden = true - error.hidden = false - } - video.addEventListener('error', showError) - let failedSources = 0 - for (const source of sources) { - source.addEventListener( - 'error', - () => { - failedSources++ - if (failedSources === sources.length) showError() - }, - { once: true }, - ) - } - content = video } - return el( - 'div', - { 'data-media-item': '', 'data-orientation': getOrientation(media) }, - content, - error, + return video +} + +/** + * A poster button. Its click plays the video in place, unless a + * `meowdown-embed-media-click` listener takes over. + */ +function renderVideo( + media: Video, + error: HTMLElement, + onClick: (element: HTMLElement) => boolean, + permalink?: string, +) { + const gif = media.type === 'gif' + const poster = media.poster + ? el('img', { + src: media.poster, + alt: '', + ...sizeAttrs(media), + loading: 'lazy', + decoding: 'async', + referrerpolicy: 'no-referrer', + }) + : undefined + const button = el( + 'button', + { + 'data-poster': '', + type: 'button', + 'aria-label': gif ? 'Play GIF' : 'Play video', + style: sizeAttrs(media).style, + }, + poster, + el('span', { 'data-play': '', 'aria-hidden': 'true' }), ) + button.addEventListener('click', () => { + if (!onClick(poster ?? button)) return + const video = renderPlayer(media, error, permalink) + button.replaceWith(video) + video.focus() + // The click is the user gesture that allows playback with sound. + video.play().catch(() => {}) + }) + return button } export function renderMedia( @@ -123,9 +189,35 @@ export function renderMedia( permalink?: string, ) { if (!media?.length) return + const displayable = media.map((item) => getDisplayable(item, protocols)) + const items = displayable.filter((item) => item != null) return el( 'div', { 'data-media': '', 'data-count': String(media.length) }, - media.map((item) => renderItem(item, protocols, permalink)), + displayable.map((item) => { + if (!item) return renderUnavailable(permalink) + const error = renderError(permalink) + const onClick = (element: HTMLElement) => { + return dispatchMediaClick(element, { + media: item, + items, + index: items.indexOf(item), + element, + permalink, + }) + } + return el( + 'div', + { + 'data-media-item': '', + 'data-type': item.type, + 'data-orientation': getOrientation(item), + }, + item.type === 'photo' + ? renderPhoto(item, error, onClick) + : renderVideo(item, error, onClick, permalink), + error, + ) + }), ) } diff --git a/packages/embed/src/x/theme.css b/packages/embed/src/x/theme.css index 0b5aaed7..fe3275c5 100644 --- a/packages/embed/src/x/theme.css +++ b/packages/embed/src/x/theme.css @@ -234,6 +234,38 @@ display: block; height: 100%; } + & [data-media-item] > [data-poster] { + display: block; + position: relative; + width: 100%; + height: 100%; + /* Holds the ratio when the snapshot has no poster image. */ + aspect-ratio: var(--_ratio, 16 / 9); + max-height: 20rem; + padding: 0; + border: 0; + background: var(--_tint); + font: inherit; + color: inherit; + cursor: pointer; + } + & [data-play] { + position: absolute; + inset: 50% auto auto 50%; + width: 3rem; + height: 3rem; + translate: -50% -50%; + border-radius: 50%; + background: rgb(0 0 0 / 60%); + } + & [data-play]::after { + content: ''; + position: absolute; + inset: 50% auto auto 54%; + translate: -50% -50%; + border-block: 0.5rem solid transparent; + border-inline-start: 0.8rem solid #fff; + } /* A single photo or video fills the width at its own ratio, up to 20rem * tall; taller media is cropped and the photo links to the original. */ & [data-media-item] img, @@ -317,12 +349,15 @@ margin-block-start: 0.5rem; border-radius: 0.375rem; } - & [data-quoted] [data-media-item] :is(img, video) { + & [data-quoted] [data-media-item] :is(img, video, [data-poster]) { max-height: 10rem; } /* Quoted photos shrink to one thumbnail beside the text, with a count * badge; a quoted video or GIF stays below the text so its player works. */ - & [data-quoted]:has(> [data-media]):not(:has(> [data-media] video)) { + & + [data-quoted]:has(> [data-media]):not( + :has(> [data-media] [data-poster], > [data-media] video) + ) { display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: diff --git a/packages/react/README.md b/packages/react/README.md index 313bd6e1..fe6c9a7b 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -61,7 +61,7 @@ Common `MeowdownEditor` props: | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `mode` | `'focus'` (default), `'show'`, or `'hide'`: how much Markdown syntax stays in view | | `searchQuery` / `onSearchChange` | Find in document, with `EditorHandle.findNext()` / `findPrevious()` | -| `onWikilinkClick` / `onLinkClick` / `onTagClick` / `onImageClick` / `onFileClick` | Click handling for the rendered atoms | +| `onWikilinkClick` / `onLinkClick` / `onTagClick` / `onImageClick` / `onFileClick` / `onXPostMediaClick` | Click handling for the rendered atoms | | `resolveImageUrl` / `resolveWikiEmbed` / `resolveWikilink` / `resolveFileLink` / `resolveFileInfo` | Resolve and classify local content | | `onFilePaste` | Persist pasted or dropped files | | `onSlashMenuSearch` / `onTagSearch` / `onWikilinkSearch` / `onSelectionMenuSearch` | Search menus for `/`, `#`, `[[`, and selection commands | diff --git a/packages/react/src/components/editor.tsx b/packages/react/src/components/editor.tsx index 62be2ea6..6dc80c88 100644 --- a/packages/react/src/components/editor.tsx +++ b/packages/react/src/components/editor.tsx @@ -6,6 +6,7 @@ import type { FilePasteOptions, FileViewOptions, ImageClickHandler, + XPostMediaClickHandler, ImageOptions, LinkClickHandler, LinkCopyHandler, @@ -264,6 +265,15 @@ export interface EditorProps { */ onImageClick?: ImageClickHandler + /** + * Called when the user activates a photo or video inside an X post card, + * with the item, its siblings, and the rendered thumbnail element. With a + * handler the card no longer opens the photo URL or plays the video in + * place: show the media yourself, for example in a lightbox. Pass a stable + * function (e.g. from `useCallback`). + */ + onXPostMediaClick?: XPostMediaClickHandler + /** * Auto-embeds a pasted tweet or YouTube link as a rich embed; one undo turns * the embed back into the raw link. On by default. @@ -409,6 +419,7 @@ export function MeowdownEditor({ onFilePaste, onFileSaveError, onImageClick, + onXPostMediaClick, embedPaste = true, linkPaste = true, bulletAfterHeading = false, @@ -550,6 +561,7 @@ export function MeowdownEditor({ onFilePaste={onFilePaste} onFileSaveError={onFileSaveError} onImageClick={onImageClick} + onXPostMediaClick={onXPostMediaClick} embedPaste={embedPaste} linkPaste={linkPaste} bulletAfterHeading={bulletAfterHeading} diff --git a/packages/react/src/components/markdown-view.test.tsx b/packages/react/src/components/markdown-view.test.tsx index 8acd7d5c..3de81b77 100644 --- a/packages/react/src/components/markdown-view.test.tsx +++ b/packages/react/src/components/markdown-view.test.tsx @@ -12,6 +12,10 @@ import { createXPost } from '../testing/x-post-fixture.ts' import { createYouTubeVideo } from '../testing/youtube-fixture.ts' import { MarkdownView } from './markdown-view.tsx' + +// 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" import { ProseKitEditor } from './prosekit-editor.tsx' const view = page.getByTestId('markdown-view') @@ -267,6 +271,25 @@ describe('MarkdownView', () => { .toHaveAttribute('src', 'reflect-asset://saved/photo.png') }) + it('reports a clicked X post photo', async () => { + const post = createXPost() + post.media = [{ type: 'photo', url: PHOTO_URL, width: 100, height: 100 }] + const onXPostMediaClick = vi.fn() + await renderView('![](https://x.com/jack/status/20)', { + resolveXPost: () => post, + mediaUrlProtocols: ['data:'], + onXPostMediaClick, + }) + const image = view.getByTestId('x-post-embed').locate('[data-media] img') + await expect.element(image).toBeInTheDocument() + await image.click() + expect(onXPostMediaClick).toHaveBeenCalledTimes(1) + expect(onXPostMediaClick.mock.calls[0][0]).toMatchObject({ + index: 0, + media: { type: 'photo', url: PHOTO_URL }, + }) + }) + it('renders an X post card from a synchronous snapshot', async () => { await renderView('![](https://x.com/jack/status/20)', { resolveXPost: () => createXPost(), diff --git a/packages/react/src/components/markdown-view.tsx b/packages/react/src/components/markdown-view.tsx index 7d18caaa..541d8491 100644 --- a/packages/react/src/components/markdown-view.tsx +++ b/packages/react/src/components/markdown-view.tsx @@ -37,8 +37,9 @@ import { type WikiEmbedResolver, type WikilinkClickHandler, type WikilinkResolver, + type XPostMediaClickHandler, } from '@meowdown/core' -import { registerXPost } from '@meowdown/embed/x' +import { registerXPost, type XPostMediaClickEvent } from '@meowdown/embed/x' import { registerYouTubeVideo } from '@meowdown/embed/youtube' import { matchEmbed, type EmbedKind } from '@meowdown/markdown' import type { DOMOutputSpec } from '@prosekit/pm/model' @@ -49,6 +50,7 @@ import { createElement, Fragment, memo, + useCallback, useEffect, useMemo, useState, @@ -180,6 +182,11 @@ export interface MarkdownViewProps { * Called when a rendered image is clicked. Pass a stable function. */ onImageClick?: ImageClickHandler + /** + * Called when a photo or video inside an X post card is activated. With a + * handler the card no longer opens the photo URL or plays the video in place. + */ + onXPostMediaClick?: XPostMediaClickHandler /** * Called when a rendered file pill is clicked. Pass a stable function. */ @@ -971,6 +978,7 @@ export function MarkdownView({ onWikilinkClick, onLinkClick, onImageClick, + onXPostMediaClick, onFileClick, onTaskClick, className, @@ -1022,8 +1030,27 @@ export function MarkdownView({ } }, [markdown, frontmatter]) + // The card's event bubbles, so one listener on the root covers every card. + const handleXPostMediaClick = interactive ? onXPostMediaClick : undefined + const rootRef = useCallback( + (root: HTMLDivElement) => { + if (!handleXPostMediaClick) return + const listener = (event: XPostMediaClickEvent) => { + event.preventDefault() + handleXPostMediaClick(event.detail) + } + root.addEventListener('meowdown-embed-media-click', listener) + return () => root.removeEventListener('meowdown-embed-media-click', listener) + }, + [handleXPostMediaClick], + ) + return ( -
+
{blocks.map(({ node, taskBase }, index) => (