2026-03-14 17:22:18 +11:00
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
2023-10-30 07:14:58 +11:00
|
|
|
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
|
|
|
|
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
2026-03-14 17:22:18 +11:00
|
|
|
import { ImageOverlay } from '../ImageOverlay';
|
2024-05-31 19:49:46 +05:30
|
|
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|
|
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
|
|
|
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
2023-10-30 07:14:58 +11:00
|
|
|
import {
|
|
|
|
|
getIntersectionObserverEntry,
|
|
|
|
|
useIntersectionObserver,
|
2024-05-31 19:49:46 +05:30
|
|
|
} from '../../hooks/useIntersectionObserver';
|
|
|
|
|
import * as css from './UrlPreviewCard.css';
|
2026-06-03 23:15:29 -04:00
|
|
|
import * as previewCss from './UrlPreview.css';
|
2024-08-04 11:08:20 +05:30
|
|
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
2024-09-07 21:45:55 +08:00
|
|
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
2024-09-09 18:45:20 +10:00
|
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
2026-03-14 17:22:18 +11:00
|
|
|
import { ImageViewer } from '../image-viewer';
|
|
|
|
|
import { onEnterOrSpace } from '../../utils/keyboard';
|
2023-10-30 07:14:58 +11:00
|
|
|
|
|
|
|
|
const linkStyles = { color: color.Success.Main };
|
|
|
|
|
|
2026-06-03 23:15:29 -04:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers — URL parsing & variant detection
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
type CardVariant = 'youtube' | 'github' | 'generic';
|
|
|
|
|
|
|
|
|
|
function getYouTubeVideoId(url: string): string | null {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
const { hostname, pathname, searchParams } = parsed;
|
|
|
|
|
|
|
|
|
|
// youtu.be/<id>
|
|
|
|
|
if (hostname === 'youtu.be') {
|
|
|
|
|
const id = pathname.slice(1).split('/')[0];
|
|
|
|
|
return id || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// youtube.com/watch?v=<id>
|
|
|
|
|
if (hostname === 'www.youtube.com' || hostname === 'youtube.com') {
|
|
|
|
|
if (pathname === '/watch') {
|
|
|
|
|
return searchParams.get('v');
|
|
|
|
|
}
|
|
|
|
|
// youtube.com/shorts/<id>
|
|
|
|
|
const shortsMatch = pathname.match(/^\/shorts\/([A-Za-z0-9_-]+)/);
|
|
|
|
|
if (shortsMatch) return shortsMatch[1];
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore malformed URLs
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGitHubRepo(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
const { hostname, pathname } = parsed;
|
|
|
|
|
if (hostname !== 'github.com' && hostname !== 'www.github.com') return false;
|
|
|
|
|
// Exactly two path segments: /<owner>/<repo> (no deeper pages)
|
|
|
|
|
const parts = pathname.replace(/\/$/, '').split('/').filter(Boolean);
|
|
|
|
|
return parts.length === 2;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCardVariant(url: string): CardVariant {
|
|
|
|
|
if (getYouTubeVideoId(url) !== null) return 'youtube';
|
|
|
|
|
if (isGitHubRepo(url)) return 'github';
|
|
|
|
|
return 'generic';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDomain(url: string): string {
|
|
|
|
|
try {
|
|
|
|
|
return new URL(url).hostname.replace(/^www\./, '');
|
|
|
|
|
} catch {
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GitHub SVG icon (inline, no external dependency)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function GitHubIcon({ size = 24 }: { size?: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<svg
|
|
|
|
|
width={size}
|
|
|
|
|
height={size}
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
role="img"
|
|
|
|
|
>
|
|
|
|
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Card variant renderers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function YouTubeCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|
|
|
|
const videoId = getYouTubeVideoId(url)!;
|
|
|
|
|
const thumbnailSrc = `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
|
|
|
|
|
const title = prev['og:title'] ?? '';
|
|
|
|
|
const description = prev['og:description'] ?? '';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<a
|
|
|
|
|
href={url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
className={previewCss.YouTubeThumbnailWrapper}
|
|
|
|
|
aria-label={`Play on YouTube: ${title}`}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
className={previewCss.YouTubeThumbnailImg}
|
|
|
|
|
src={thumbnailSrc}
|
|
|
|
|
alt={title}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
/>
|
|
|
|
|
<div className={previewCss.YouTubePlayOverlay}>
|
|
|
|
|
<div className={previewCss.YouTubePlayButton}>
|
|
|
|
|
<Icon size="400" src={Icons.Play} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</a>
|
|
|
|
|
<UrlPreviewContent>
|
|
|
|
|
<Text
|
|
|
|
|
style={linkStyles}
|
|
|
|
|
truncate
|
|
|
|
|
as="a"
|
|
|
|
|
href={url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
size="T200"
|
|
|
|
|
priority="300"
|
|
|
|
|
>
|
|
|
|
|
YouTube
|
|
|
|
|
</Text>
|
|
|
|
|
{title && (
|
|
|
|
|
<Text truncate priority="400">
|
|
|
|
|
<b>{title}</b>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
{description && (
|
|
|
|
|
<Text size="T200" priority="300">
|
|
|
|
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</UrlPreviewContent>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
|
|
|
|
const title = prev['og:title'] ?? '';
|
|
|
|
|
const description = prev['og:description'] ?? '';
|
|
|
|
|
|
|
|
|
|
// GitHub og:title is usually "owner/repo: short description" — split on ': '
|
|
|
|
|
const colonIdx = title.indexOf(': ');
|
|
|
|
|
const repoName = colonIdx !== -1 ? title.slice(0, colonIdx) : title;
|
|
|
|
|
const repoDesc = colonIdx !== -1 ? title.slice(colonIdx + 2) : description;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Box
|
|
|
|
|
shrink="No"
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
justifyContent="Center"
|
|
|
|
|
className={previewCss.GitHubIconWrapper}
|
|
|
|
|
>
|
|
|
|
|
<GitHubIcon size={28} />
|
|
|
|
|
</Box>
|
|
|
|
|
<UrlPreviewContent>
|
|
|
|
|
<Text
|
|
|
|
|
style={linkStyles}
|
|
|
|
|
truncate
|
|
|
|
|
as="a"
|
|
|
|
|
href={url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
size="T200"
|
|
|
|
|
priority="300"
|
|
|
|
|
>
|
|
|
|
|
GitHub
|
|
|
|
|
</Text>
|
|
|
|
|
{repoName && (
|
|
|
|
|
<Text truncate priority="400">
|
|
|
|
|
<b>{repoName}</b>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
{repoDesc && (
|
|
|
|
|
<Text size="T200" priority="300">
|
|
|
|
|
<UrlPreviewDescription>{repoDesc}</UrlPreviewDescription>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
{description && description !== repoDesc && (
|
|
|
|
|
<Text size="T200" priority="300">
|
|
|
|
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</UrlPreviewContent>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function GenericCard({
|
|
|
|
|
url,
|
|
|
|
|
prev,
|
|
|
|
|
onOpenViewer,
|
|
|
|
|
viewer,
|
|
|
|
|
onCloseViewer,
|
|
|
|
|
thumbUrl,
|
|
|
|
|
imgUrl,
|
|
|
|
|
}: {
|
|
|
|
|
url: string;
|
|
|
|
|
prev: IPreviewUrlResponse;
|
|
|
|
|
onOpenViewer: () => void;
|
|
|
|
|
viewer: boolean;
|
|
|
|
|
onCloseViewer: () => void;
|
|
|
|
|
thumbUrl: string | null;
|
|
|
|
|
imgUrl: string | null;
|
|
|
|
|
}) {
|
|
|
|
|
const title = prev['og:title'] ?? '';
|
|
|
|
|
const description = prev['og:description'] ?? '';
|
|
|
|
|
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
|
|
|
|
|
const domain = getDomain(url);
|
|
|
|
|
const faviconSrc = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16`;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{thumbUrl && (
|
|
|
|
|
<UrlPreviewImg
|
|
|
|
|
src={thumbUrl}
|
|
|
|
|
alt={prev['og:title']}
|
|
|
|
|
title={prev['og:title']}
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
|
|
|
|
onClick={onOpenViewer}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{imgUrl && (
|
|
|
|
|
<ImageOverlay
|
|
|
|
|
src={imgUrl}
|
|
|
|
|
alt={prev['og:title']}
|
|
|
|
|
viewer={viewer}
|
|
|
|
|
requestClose={onCloseViewer}
|
|
|
|
|
renderViewer={(p) => <ImageViewer {...p} />}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<UrlPreviewContent>
|
|
|
|
|
<Text
|
|
|
|
|
style={linkStyles}
|
|
|
|
|
truncate
|
|
|
|
|
as="a"
|
|
|
|
|
href={url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
size="T200"
|
|
|
|
|
priority="300"
|
|
|
|
|
>
|
|
|
|
|
{!thumbUrl && (
|
|
|
|
|
<img
|
|
|
|
|
className={previewCss.GenericFaviconImg}
|
|
|
|
|
src={faviconSrc}
|
|
|
|
|
alt=""
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
loading="lazy"
|
|
|
|
|
style={{ marginRight: '4px', verticalAlign: 'text-bottom' }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{siteName ? `${siteName} | ` : ''}
|
|
|
|
|
{tryDecodeURIComponent(url)}
|
|
|
|
|
</Text>
|
|
|
|
|
{title && (
|
|
|
|
|
<Text truncate priority="400">
|
|
|
|
|
<b>{title}</b>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
{description && (
|
|
|
|
|
<Text size="T200" priority="300">
|
|
|
|
|
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</UrlPreviewContent>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Main UrlPreviewCard component
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-03-14 17:22:18 +11:00
|
|
|
export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|
|
|
|
({ url, ts, ...props }, ref) => {
|
|
|
|
|
const mx = useMatrixClient();
|
|
|
|
|
const useAuthentication = useMediaAuthentication();
|
|
|
|
|
const [viewer, setViewer] = useState(false);
|
|
|
|
|
const [previewStatus, loadPreview] = useAsyncCallback(
|
2026-05-21 23:30:50 -04:00
|
|
|
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]),
|
2026-03-14 17:22:18 +11:00
|
|
|
);
|
2024-01-21 23:50:56 +11:00
|
|
|
|
2026-03-14 17:22:18 +11:00
|
|
|
useEffect(() => {
|
2026-05-15 19:07:13 -04:00
|
|
|
loadPreview().catch(() => {});
|
2026-03-14 17:22:18 +11:00
|
|
|
}, [loadPreview]);
|
2023-10-30 07:14:58 +11:00
|
|
|
|
2026-03-14 17:22:18 +11:00
|
|
|
if (previewStatus.status === AsyncStatus.Error) return null;
|
2023-10-30 07:14:58 +11:00
|
|
|
|
2026-06-03 23:15:29 -04:00
|
|
|
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
|
|
|
|
const variant = getCardVariant(url);
|
|
|
|
|
|
|
|
|
|
if (variant === 'youtube') {
|
|
|
|
|
return <YouTubeCard url={url} prev={prev} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (variant === 'github') {
|
|
|
|
|
return <GitHubCard url={url} prev={prev} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generic fallback — skip empty cards
|
|
|
|
|
if (!prev['og:title'] && !prev['og:description']) return null;
|
|
|
|
|
|
2026-03-14 17:22:18 +11:00
|
|
|
const thumbUrl = mxcUrlToHttp(
|
|
|
|
|
mx,
|
|
|
|
|
prev['og:image'] || '',
|
|
|
|
|
useAuthentication,
|
|
|
|
|
256,
|
|
|
|
|
256,
|
|
|
|
|
'scale',
|
2026-05-21 23:30:50 -04:00
|
|
|
false,
|
2026-03-14 17:22:18 +11:00
|
|
|
);
|
|
|
|
|
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
2023-10-30 07:14:58 +11:00
|
|
|
|
2026-03-14 17:22:18 +11:00
|
|
|
return (
|
2026-06-03 23:15:29 -04:00
|
|
|
<GenericCard
|
|
|
|
|
url={url}
|
|
|
|
|
prev={prev}
|
|
|
|
|
onOpenViewer={() => setViewer(true)}
|
|
|
|
|
viewer={viewer}
|
|
|
|
|
onCloseViewer={() => setViewer(false)}
|
|
|
|
|
thumbUrl={thumbUrl}
|
|
|
|
|
imgUrl={imgUrl}
|
|
|
|
|
/>
|
2026-03-14 17:22:18 +11:00
|
|
|
);
|
|
|
|
|
};
|
2023-10-30 07:14:58 +11:00
|
|
|
|
2026-06-03 23:15:29 -04:00
|
|
|
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
|
|
|
|
if (previewStatus.status === AsyncStatus.Success) {
|
|
|
|
|
const content = renderContent(previewStatus.data);
|
|
|
|
|
if (content === null) return null;
|
|
|
|
|
return (
|
|
|
|
|
<UrlPreview {...props} ref={ref}>
|
|
|
|
|
{content}
|
|
|
|
|
</UrlPreview>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-30 07:14:58 +11:00
|
|
|
return (
|
2026-03-14 17:22:18 +11:00
|
|
|
<UrlPreview {...props} ref={ref}>
|
2026-06-03 23:15:29 -04:00
|
|
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
|
|
|
|
<Spinner variant="Secondary" size="400" />
|
|
|
|
|
</Box>
|
2026-03-14 17:22:18 +11:00
|
|
|
</UrlPreview>
|
2023-10-30 07:14:58 +11:00
|
|
|
);
|
2026-05-21 23:30:50 -04:00
|
|
|
},
|
2026-03-14 17:22:18 +11:00
|
|
|
);
|
2023-10-30 07:14:58 +11:00
|
|
|
|
|
|
|
|
export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const backAnchorRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const frontAnchorRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [backVisible, setBackVisible] = useState(true);
|
|
|
|
|
const [frontVisible, setFrontVisible] = useState(true);
|
|
|
|
|
|
|
|
|
|
const intersectionObserver = useIntersectionObserver(
|
|
|
|
|
useCallback((entries) => {
|
|
|
|
|
const backAnchor = backAnchorRef.current;
|
|
|
|
|
const frontAnchor = frontAnchorRef.current;
|
|
|
|
|
const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries);
|
|
|
|
|
const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries);
|
|
|
|
|
if (backEntry) {
|
|
|
|
|
setBackVisible(backEntry.isIntersecting);
|
|
|
|
|
}
|
|
|
|
|
if (frontEntry) {
|
|
|
|
|
setFrontVisible(frontEntry.isIntersecting);
|
|
|
|
|
}
|
|
|
|
|
}, []),
|
|
|
|
|
useCallback(
|
|
|
|
|
() => ({
|
|
|
|
|
root: scrollRef.current,
|
|
|
|
|
rootMargin: '10px',
|
|
|
|
|
}),
|
2026-05-21 23:30:50 -04:00
|
|
|
[],
|
|
|
|
|
),
|
2023-10-30 07:14:58 +11:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const backAnchor = backAnchorRef.current;
|
|
|
|
|
const frontAnchor = frontAnchorRef.current;
|
|
|
|
|
if (backAnchor) intersectionObserver?.observe(backAnchor);
|
|
|
|
|
if (frontAnchor) intersectionObserver?.observe(frontAnchor);
|
|
|
|
|
return () => {
|
|
|
|
|
if (backAnchor) intersectionObserver?.unobserve(backAnchor);
|
|
|
|
|
if (frontAnchor) intersectionObserver?.unobserve(frontAnchor);
|
|
|
|
|
};
|
|
|
|
|
}, [intersectionObserver]);
|
|
|
|
|
|
|
|
|
|
const handleScrollBack = () => {
|
|
|
|
|
const scroll = scrollRef.current;
|
|
|
|
|
if (!scroll) return;
|
|
|
|
|
const { offsetWidth, scrollLeft } = scroll;
|
|
|
|
|
scroll.scrollTo({
|
|
|
|
|
left: scrollLeft - offsetWidth / 1.3,
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
const handleScrollFront = () => {
|
|
|
|
|
const scroll = scrollRef.current;
|
|
|
|
|
if (!scroll) return;
|
|
|
|
|
const { offsetWidth, scrollLeft } = scroll;
|
|
|
|
|
scroll.scrollTo({
|
|
|
|
|
left: scrollLeft + offsetWidth / 1.3,
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
direction="Column"
|
|
|
|
|
{...props}
|
|
|
|
|
ref={ref}
|
|
|
|
|
style={{ marginTop: config.space.S200, position: 'relative' }}
|
|
|
|
|
>
|
|
|
|
|
<Scroll ref={scrollRef} direction="Horizontal" size="0" visibility="Hover" hideTrack>
|
|
|
|
|
<Box shrink="No" alignItems="Center">
|
|
|
|
|
<div ref={backAnchorRef} />
|
|
|
|
|
{!backVisible && (
|
|
|
|
|
<>
|
|
|
|
|
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
|
|
|
|
|
<IconButton
|
|
|
|
|
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label="Previous preview"
|
2023-10-30 07:14:58 +11:00
|
|
|
variant="Secondary"
|
|
|
|
|
radii="Pill"
|
|
|
|
|
size="300"
|
|
|
|
|
outlined
|
|
|
|
|
onClick={handleScrollBack}
|
|
|
|
|
>
|
|
|
|
|
<Icon size="300" src={Icons.ArrowLeft} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<Box alignItems="Inherit" gap="200">
|
|
|
|
|
{children}
|
|
|
|
|
|
|
|
|
|
{!frontVisible && (
|
|
|
|
|
<>
|
|
|
|
|
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
|
|
|
|
|
<IconButton
|
|
|
|
|
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label="Next preview"
|
2023-10-30 07:14:58 +11:00
|
|
|
variant="Primary"
|
|
|
|
|
radii="Pill"
|
|
|
|
|
size="300"
|
|
|
|
|
outlined
|
|
|
|
|
onClick={handleScrollFront}
|
|
|
|
|
>
|
|
|
|
|
<Icon size="300" src={Icons.ArrowRight} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<div ref={frontAnchorRef} />
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</Scroll>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
});
|