// OD Library — full-size, kind-aware asset preview. // // The grid only shows a thumbnail; this modal renders the asset for real: // image → contained // video → // html / // design-system → sandboxed (scripts run so captured pages / CSS+JS // animations actually move, but it sits in an opaque origin and // cannot reach the daemon — matches the rest of the app's // untrusted-HTML rendering) // font → live specimen (alphabet + sizes) via an injected @font-face // color → large swatch + the resolved value // text → the raw text in a // url → the captured link with an open-in-new-tab affordance // // Plus a metadata bar (kind / source / dimensions / size / date / tags) and // prev/next navigation so the user can flip through the grid without closing. // // Copy is intentionally inline (not yet i18n-keyed), matching LibrarySection. import { type CSSProperties, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { motion } from 'motion/react'; import type { LibraryAsset } from '@open-design/contracts'; import { Button } from '@open-design/components'; import { libraryAssetElementUrl, libraryAssetFigmaUrl, libraryAssetRawUrl } from '../providers/registry'; import { modalOverlay, modalContent } from '../motion'; import { KindIcon, SOURCE_LABELS, assetTitle, badgeKind, colorOf, elementMetaOf, fontFamilyFor, formatBytes, formatDate, kindLabel, kindTint, originProjectId, primarySource, } from './LibraryAssetMeta'; import styles from './LibraryPreviewModal.module.css'; interface Props { asset: LibraryAsset; hasPrev: boolean; hasNext: boolean; onPrev: () => void; onNext: () => void; onClose: () => void; onDelete: (id: string) => void; onOpenProject?: (projectId: string, fileName?: string) => void; /** Turn a captured `html` asset into a new editable OD project (html only). */ onEditAsPage?: (assetId: string) => Promise; } /** Fetch an asset's raw bytes as text (for text / color / url kinds). */ function useRawText(rawUrl: string, enabled: boolean) { const [state, setState] = useState<{ text: string | null; loading: boolean; error: boolean }>({ text: null, loading: enabled, error: false, }); useEffect(() => { if (!enabled) return; let cancelled = false; setState({ text: null, loading: true, error: false }); fetch(rawUrl) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) .then((text) => { if (!cancelled) setState({ text, loading: false, error: false }); }) .catch(() => { if (!cancelled) setState({ text: null, loading: false, error: true }); }); return () => { cancelled = true; }; }, [rawUrl, enabled]); return state; } function FontSpecimen({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) { const family = fontFamilyFor(asset.id); return ( {/* Self-contained @font-face so the specimen works even outside the grid. */} Ag ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 & ! ? @ # $ % The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. ); } function ColorStage({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) { const needsText = !asset.palette?.length; const { text, loading } = useRawText(rawUrl, needsText); const value = colorOf(asset, text); if (loading && !value) return Loading…; if (!value) return No color value available.; const swatches = asset.palette?.length ? asset.palette : [value]; return ( {value} {swatches.length > 1 ? ( {swatches.map((c, i) => ( ))} ) : null} ); } function TextStage({ rawUrl }: { rawUrl: string }) { const { text, loading, error } = useRawText(rawUrl, true); if (loading) return Loading…; if (error) return Could not load text.; return {text}; } function UrlStage({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) { const needsText = !asset.sourceUrl; const { text } = useRawText(rawUrl, needsText); const href = asset.sourceUrl || (text ?? '').trim(); if (!href) return No link available.; return ( {href} Open in new tab → ); } function Stage({ asset }: { asset: LibraryAsset }) { const rawUrl = libraryAssetRawUrl(asset.id); const title = assetTitle(asset); switch (asset.kind) { case 'image': return ; case 'video': return ; case 'design-system': case 'html': // Opaque-origin sandbox: scripts/animations run, daemon stays unreachable. return ; case 'font': return ; case 'color': return ; case 'url': return ; case 'text': default: return ; } } /** * Element-pick captures carry `metadata.element`; this surfaces the captured * element's selector/size beneath the rendered preview. Current clips are * self-contained `html` assets (the stage iframe already renders the element), * so there is no separate markup to load — the "Show HTML" affordance only * appears for legacy `image` screenshots that still have an `.element.html` * sidecar (`element.hasHtml`). Inline-styled so it stays self-contained against * the modal's stylesheet. */ function ElementPanel({ asset }: { asset: LibraryAsset }) { const element = elementMetaOf(asset); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const { text, loading, error } = useRawText(libraryAssetElementUrl(asset.id), open && Boolean(element?.hasHtml)); if (!element) return null; const dims = element.width && element.height ? `${element.width}×${element.height}` : null; const copy = () => { if (!text || !navigator.clipboard) return; void navigator.clipboard.writeText(text).then( () => { setCopied(true); setTimeout(() => setCopied(false), 1500); }, () => {}, ); }; const chipBtn: CSSProperties = { font: 'inherit', fontSize: 12, fontWeight: 600, color: 'var(--accent, #2563eb)', background: 'transparent', border: '1px solid var(--border, #e5e7eb)', borderRadius: 6, padding: '3px 9px', cursor: 'pointer', }; return ( {element.selector || element.tag} {dims ? {dims} : null} {element.hasHtml ? ( setOpen((o) => !o)}> {open ? 'Hide HTML' : 'Show HTML'} ) : null} {open && element.hasHtml ? ( loading ? ( Loading… ) : error ? ( Could not load element HTML. ) : ( {copied ? 'Copied' : 'Copy'} {text} ) ) : null} ); } export function LibraryPreviewModal({ asset, hasPrev, hasNext, onPrev, onNext, onClose, onDelete, onOpenProject, onEditAsPage, }: Props) { const [editing, setEditing] = useState(false); const editAsPage = useCallback(async () => { if (!onEditAsPage) return; setEditing(true); try { await onEditAsPage(asset.id); } finally { setEditing(false); } }, [onEditAsPage, asset.id]); const src = primarySource(asset); const projectId = originProjectId(asset); const rawUrl = libraryAssetRawUrl(asset.id); const title = assetTitle(asset); const dims = asset.width && asset.height ? `${asset.width}×${asset.height}` : null; const size = formatBytes(asset.size); const date = formatDate(asset.capturedAt); // Clipper-captured pages carry an OD Figma capture; only then can we export // it as Figma import JSON. const hasFigmaCapture = asset.kind === 'html' && Boolean((asset.metadata as { figmaCapture?: unknown } | undefined)?.figmaCapture); const handleKey = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } else if (e.key === 'ArrowLeft' && hasPrev) { onPrev(); } else if (e.key === 'ArrowRight' && hasNext) { onNext(); } }, [onClose, onPrev, onNext, hasPrev, hasNext], ); useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [handleKey]); const modal = ( e.stopPropagation()} variants={modalContent} initial="hidden" animate="visible" exit="exit" role="dialog" aria-modal="true" aria-label={title} > {kindLabel(badgeKind(asset))} {title} {src ? {SOURCE_LABELS[src]} : null} {hasPrev ? ( ) : null} {hasNext ? ( ) : null} ); if (typeof document === 'undefined') return modal; return createPortal(modal, document.body); }
// url → the captured link with an open-in-new-tab affordance // // Plus a metadata bar (kind / source / dimensions / size / date / tags) and // prev/next navigation so the user can flip through the grid without closing. // // Copy is intentionally inline (not yet i18n-keyed), matching LibrarySection. import { type CSSProperties, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { motion } from 'motion/react'; import type { LibraryAsset } from '@open-design/contracts'; import { Button } from '@open-design/components'; import { libraryAssetElementUrl, libraryAssetFigmaUrl, libraryAssetRawUrl } from '../providers/registry'; import { modalOverlay, modalContent } from '../motion'; import { KindIcon, SOURCE_LABELS, assetTitle, badgeKind, colorOf, elementMetaOf, fontFamilyFor, formatBytes, formatDate, kindLabel, kindTint, originProjectId, primarySource, } from './LibraryAssetMeta'; import styles from './LibraryPreviewModal.module.css'; interface Props { asset: LibraryAsset; hasPrev: boolean; hasNext: boolean; onPrev: () => void; onNext: () => void; onClose: () => void; onDelete: (id: string) => void; onOpenProject?: (projectId: string, fileName?: string) => void; /** Turn a captured `html` asset into a new editable OD project (html only). */ onEditAsPage?: (assetId: string) => Promise; } /** Fetch an asset's raw bytes as text (for text / color / url kinds). */ function useRawText(rawUrl: string, enabled: boolean) { const [state, setState] = useState<{ text: string | null; loading: boolean; error: boolean }>({ text: null, loading: enabled, error: false, }); useEffect(() => { if (!enabled) return; let cancelled = false; setState({ text: null, loading: true, error: false }); fetch(rawUrl) .then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))) .then((text) => { if (!cancelled) setState({ text, loading: false, error: false }); }) .catch(() => { if (!cancelled) setState({ text: null, loading: false, error: true }); }); return () => { cancelled = true; }; }, [rawUrl, enabled]); return state; } function FontSpecimen({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) { const family = fontFamilyFor(asset.id); return ( {/* Self-contained @font-face so the specimen works even outside the grid. */} Ag ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 & ! ? @ # $ % The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. ); } function ColorStage({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) { const needsText = !asset.palette?.length; const { text, loading } = useRawText(rawUrl, needsText); const value = colorOf(asset, text); if (loading && !value) return Loading…; if (!value) return No color value available.; const swatches = asset.palette?.length ? asset.palette : [value]; return ( {value} {swatches.length > 1 ? ( {swatches.map((c, i) => ( ))} ) : null} ); } function TextStage({ rawUrl }: { rawUrl: string }) { const { text, loading, error } = useRawText(rawUrl, true); if (loading) return Loading…; if (error) return Could not load text.; return {text}; } function UrlStage({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) { const needsText = !asset.sourceUrl; const { text } = useRawText(rawUrl, needsText); const href = asset.sourceUrl || (text ?? '').trim(); if (!href) return No link available.; return ( {href} Open in new tab → ); } function Stage({ asset }: { asset: LibraryAsset }) { const rawUrl = libraryAssetRawUrl(asset.id); const title = assetTitle(asset); switch (asset.kind) { case 'image': return ; case 'video': return ; case 'design-system': case 'html': // Opaque-origin sandbox: scripts/animations run, daemon stays unreachable. return ; case 'font': return ; case 'color': return ; case 'url': return ; case 'text': default: return ; } } /** * Element-pick captures carry `metadata.element`; this surfaces the captured * element's selector/size beneath the rendered preview. Current clips are * self-contained `html` assets (the stage iframe already renders the element), * so there is no separate markup to load — the "Show HTML" affordance only * appears for legacy `image` screenshots that still have an `.element.html` * sidecar (`element.hasHtml`). Inline-styled so it stays self-contained against * the modal's stylesheet. */ function ElementPanel({ asset }: { asset: LibraryAsset }) { const element = elementMetaOf(asset); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const { text, loading, error } = useRawText(libraryAssetElementUrl(asset.id), open && Boolean(element?.hasHtml)); if (!element) return null; const dims = element.width && element.height ? `${element.width}×${element.height}` : null; const copy = () => { if (!text || !navigator.clipboard) return; void navigator.clipboard.writeText(text).then( () => { setCopied(true); setTimeout(() => setCopied(false), 1500); }, () => {}, ); }; const chipBtn: CSSProperties = { font: 'inherit', fontSize: 12, fontWeight: 600, color: 'var(--accent, #2563eb)', background: 'transparent', border: '1px solid var(--border, #e5e7eb)', borderRadius: 6, padding: '3px 9px', cursor: 'pointer', }; return ( {element.selector || element.tag} {dims ? {dims} : null} {element.hasHtml ? ( setOpen((o) => !o)}> {open ? 'Hide HTML' : 'Show HTML'} ) : null} {open && element.hasHtml ? ( loading ? ( Loading… ) : error ? ( Could not load element HTML. ) : ( {copied ? 'Copied' : 'Copy'} {text} ) ) : null} ); } export function LibraryPreviewModal({ asset, hasPrev, hasNext, onPrev, onNext, onClose, onDelete, onOpenProject, onEditAsPage, }: Props) { const [editing, setEditing] = useState(false); const editAsPage = useCallback(async () => { if (!onEditAsPage) return; setEditing(true); try { await onEditAsPage(asset.id); } finally { setEditing(false); } }, [onEditAsPage, asset.id]); const src = primarySource(asset); const projectId = originProjectId(asset); const rawUrl = libraryAssetRawUrl(asset.id); const title = assetTitle(asset); const dims = asset.width && asset.height ? `${asset.width}×${asset.height}` : null; const size = formatBytes(asset.size); const date = formatDate(asset.capturedAt); // Clipper-captured pages carry an OD Figma capture; only then can we export // it as Figma import JSON. const hasFigmaCapture = asset.kind === 'html' && Boolean((asset.metadata as { figmaCapture?: unknown } | undefined)?.figmaCapture); const handleKey = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } else if (e.key === 'ArrowLeft' && hasPrev) { onPrev(); } else if (e.key === 'ArrowRight' && hasNext) { onNext(); } }, [onClose, onPrev, onNext, hasPrev, hasNext], ); useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [handleKey]); const modal = ( e.stopPropagation()} variants={modalContent} initial="hidden" animate="visible" exit="exit" role="dialog" aria-modal="true" aria-label={title} > {kindLabel(badgeKind(asset))} {title} {src ? {SOURCE_LABELS[src]} : null} {hasPrev ? ( ) : null} {hasNext ? ( ) : null} ); if (typeof document === 'undefined') return modal; return createPortal(modal, document.body); }
Ag
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 & ! ? @ # $ %
The quick brown fox jumps over the lazy dog.
{value}
{text}
{element.selector || element.tag}