mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): preview linked session files (#627)
This commit is contained in:
@@ -27,8 +27,12 @@ export function HappyChatProvider(props: { value: HappyChatContextValue; childre
|
||||
)
|
||||
}
|
||||
|
||||
export function useOptionalHappyChatContext(): HappyChatContextValue | null {
|
||||
return useContext(HappyChatContext)
|
||||
}
|
||||
|
||||
export function useHappyChatContext(): HappyChatContextValue {
|
||||
const ctx = useContext(HappyChatContext)
|
||||
const ctx = useOptionalHappyChatContext()
|
||||
if (!ctx) {
|
||||
throw new Error('HappyChatContext is missing')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import '@assistant-ui/react-markdown/styles/dot.css'
|
||||
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
import type { ComponentPropsWithoutRef, MouseEvent } from 'react'
|
||||
import {
|
||||
MarkdownTextPrimitive,
|
||||
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
|
||||
@@ -11,16 +11,19 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkDisableIndentedCode from '@/lib/remark-disable-indented-code'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import remarkStripCjkAutolink from '@/lib/remark-strip-cjk-autolink'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, encodeBase64 } from '@/lib/utils'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
|
||||
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
|
||||
|
||||
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
|
||||
|
||||
export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
export const MARKDOWN_PLUGINS = [remarkGfm, remarkStripCjkAutolink, remarkMath, remarkDisableIndentedCode, remarkFilePathLinks] satisfies NonNullable<MarkdownTextPrimitiveProps['remarkPlugins']>
|
||||
export const MARKDOWN_REHYPE_PLUGINS = [rehypeKatex] satisfies NonNullable<MarkdownTextPrimitiveProps['rehypePlugins']>
|
||||
export const MARKDOWN_CLASSNAME = 'aui-md happy-chat-text min-w-0 max-w-full break-words text-[var(--app-fg)]'
|
||||
export const MARKDOWN_COMPONENTS_BY_LANGUAGE = {
|
||||
@@ -89,8 +92,47 @@ function Code(props: ComponentPropsWithoutRef<'code'>) {
|
||||
)
|
||||
}
|
||||
|
||||
function A(props: ComponentPropsWithoutRef<'a'>) {
|
||||
function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) {
|
||||
const navigate = useNavigate()
|
||||
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
|
||||
const search = new URLSearchParams({ path: encodeBase64(props.filePath) }).toString()
|
||||
const href = `/sessions/${encodeURIComponent(props.sessionId)}/file?${search}`
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
props.onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
|
||||
event.preventDefault()
|
||||
void navigate({
|
||||
to: '/sessions/$sessionId/file',
|
||||
params: { sessionId: props.sessionId },
|
||||
search: { path: encodeBase64(props.filePath) }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={href}
|
||||
rel={rel}
|
||||
onClick={handleClick}
|
||||
className={cn('aui-md-a font-medium text-[var(--app-link)] underline decoration-[color:var(--app-link-muted)] underline-offset-3', props.className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function A(props: ComponentPropsWithoutRef<'a'>) {
|
||||
const chat = useOptionalHappyChatContext()
|
||||
const filePath = typeof props.href === 'string' ? decodeFilePathHref(props.href) : null
|
||||
const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel
|
||||
|
||||
if (filePath) {
|
||||
if (!chat) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
return <FilePathAnchor {...props} filePath={filePath} sessionId={chat.sessionId} />
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeFilePathHref, remarkFilePathLinks } from '@/lib/remark-file-path-links'
|
||||
|
||||
type TestNode = {
|
||||
type: string
|
||||
value?: string
|
||||
url?: string
|
||||
children?: TestNode[]
|
||||
}
|
||||
|
||||
function transform(text: string): TestNode[] {
|
||||
const tree: TestNode = {
|
||||
type: 'root',
|
||||
children: [{ type: 'paragraph', children: [{ type: 'text', value: text }] }]
|
||||
}
|
||||
remarkFilePathLinks()(tree)
|
||||
return tree.children?.[0]?.children ?? []
|
||||
}
|
||||
|
||||
function linkedPath(node: TestNode): string | null {
|
||||
return typeof node.url === 'string' ? decodeFilePathHref(node.url) : null
|
||||
}
|
||||
|
||||
describe('remarkFilePathLinks', () => {
|
||||
it('links relative code paths and strips line suffixes from the target path', () => {
|
||||
const nodes = transform('Open web/src/router.tsx:42 please')
|
||||
const link = nodes.find((node) => node.type === 'link')
|
||||
|
||||
expect(link?.children?.[0]?.value).toBe('web/src/router.tsx:42')
|
||||
expect(linkedPath(link!)).toBe('web/src/router.tsx')
|
||||
})
|
||||
|
||||
it('links image and markdown filenames for preview', () => {
|
||||
const nodes = transform('See screenshot.png and README.md')
|
||||
const links = nodes.filter((node) => node.type === 'link')
|
||||
|
||||
expect(links.map(linkedPath)).toEqual(['screenshot.png', 'README.md'])
|
||||
})
|
||||
|
||||
|
||||
it('does not link paths that are outside the session workspace', () => {
|
||||
const nodes = transform('Skip /Users/dev/project/a.png, ~/a.png, ../a.png and C:\\tmp\\a.png')
|
||||
|
||||
expect(nodes.some((node) => node.type === 'link')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not rewrite ordinary urls', () => {
|
||||
const nodes = transform('Visit https://example.com/web/src/router.tsx')
|
||||
|
||||
expect(nodes.some((node) => node.type === 'link')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
const FILE_PATH_HREF_PREFIX = 'hapi-file:'
|
||||
|
||||
const PATH_PATTERN = /(?:\.\/|[A-Za-z0-9_.-]+\/)[^\s`"\'<>]*?\.(?:[A-Za-z0-9]{1,12}|lock)(?::\d+(?::\d+)?)?|(?:[A-Za-z0-9_.-]+\.(?:[A-Za-z0-9]{1,12}|lock))(?::\d+(?::\d+)?)?/g
|
||||
|
||||
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?'])
|
||||
const COMMON_FILE_EXTENSIONS = new Set([
|
||||
'avif', 'bmp', 'c', 'cjs', 'cpp', 'css', 'gif', 'go', 'h', 'hpp', 'html', 'ico', 'java',
|
||||
'jpeg', 'jpg', 'js', 'json', 'jsx', 'kt', 'lock', 'md', 'mdx', 'mjs', 'png', 'py', 'rs',
|
||||
'scss', 'sh', 'sql', 'svg', 'swift', 'toml', 'ts', 'tsx', 'txt', 'vue', 'webp', 'xml',
|
||||
'yaml', 'yml', 'zsh'
|
||||
])
|
||||
|
||||
type MarkdownNode = {
|
||||
type?: string
|
||||
value?: string
|
||||
url?: string
|
||||
title?: string | null
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
function createFileHref(path: string): string {
|
||||
return `${FILE_PATH_HREF_PREFIX}${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
export function decodeFilePathHref(href: string): string | null {
|
||||
if (!href.startsWith(FILE_PATH_HREF_PREFIX)) return null
|
||||
try {
|
||||
return decodeURIComponent(href.slice(FILE_PATH_HREF_PREFIX.length))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function splitTrailingPunctuation(value: string): { path: string; trailing: string } {
|
||||
let path = value
|
||||
let trailing = ''
|
||||
|
||||
while (path.length > 0) {
|
||||
const last = path[path.length - 1]
|
||||
if (TRAILING_PUNCTUATION.has(last)) {
|
||||
trailing = last + trailing
|
||||
path = path.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
if (last === ')' && path.split('(').length <= path.split(')').length) {
|
||||
trailing = last + trailing
|
||||
path = path.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
if (last === ']' || last === '}') {
|
||||
trailing = last + trailing
|
||||
path = path.slice(0, -1)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return { path, trailing }
|
||||
}
|
||||
|
||||
function stripLineSuffix(value: string): string {
|
||||
return value.replace(/:\d+(?::\d+)?$/, '')
|
||||
}
|
||||
|
||||
function hasKnownFileExtension(value: string): boolean {
|
||||
const path = stripLineSuffix(value).toLowerCase()
|
||||
const ext = path.slice(path.lastIndexOf('.') + 1)
|
||||
return COMMON_FILE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
function shouldLinkPath(value: string): boolean {
|
||||
if (value.includes('://')) return false
|
||||
const path = stripLineSuffix(value)
|
||||
if (path.length < 3) return false
|
||||
if (path.startsWith('/') || path.startsWith('~/')) return false
|
||||
if (path.startsWith('../') || path.includes('/../')) return false
|
||||
if (/^[A-Za-z]:[\\/]/.test(path)) return false
|
||||
if (path.includes('/')) return hasKnownFileExtension(path)
|
||||
return hasKnownFileExtension(path)
|
||||
}
|
||||
|
||||
function linkTextNode(node: MarkdownNode): MarkdownNode[] {
|
||||
const value = node.value ?? ''
|
||||
const parts: MarkdownNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
PATH_PATTERN.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = PATH_PATTERN.exec(value)) !== null) {
|
||||
const rawMatch = match[0]
|
||||
const previousChar = match.index > 0 ? value[match.index - 1] : ''
|
||||
if (previousChar === ':' || previousChar === '/' || previousChar === '\\' || previousChar === '.') {
|
||||
continue
|
||||
}
|
||||
const { path: displayPath, trailing } = splitTrailingPunctuation(rawMatch)
|
||||
const filePath = stripLineSuffix(displayPath)
|
||||
|
||||
if (!shouldLinkPath(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', value: value.slice(lastIndex, match.index) })
|
||||
}
|
||||
parts.push({
|
||||
type: 'link',
|
||||
url: createFileHref(filePath),
|
||||
title: null,
|
||||
children: [{ type: 'text', value: displayPath }]
|
||||
})
|
||||
if (trailing) {
|
||||
parts.push({ type: 'text', value: trailing })
|
||||
}
|
||||
lastIndex = match.index + rawMatch.length
|
||||
}
|
||||
|
||||
if (parts.length === 0) return [node]
|
||||
if (lastIndex < value.length) {
|
||||
parts.push({ type: 'text', value: value.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function visit(node: MarkdownNode, parentType: string | null = null): void {
|
||||
if (!node.children) return
|
||||
if (parentType === 'link' || parentType === 'linkReference') return
|
||||
|
||||
const nextChildren: MarkdownNode[] = []
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'text') {
|
||||
nextChildren.push(...linkTextNode(child))
|
||||
continue
|
||||
}
|
||||
visit(child, child.type ?? null)
|
||||
nextChildren.push(child)
|
||||
}
|
||||
node.children = nextChildren
|
||||
}
|
||||
|
||||
export function remarkFilePathLinks() {
|
||||
return (tree: MarkdownNode) => visit(tree)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams, useSearch } from '@tanstack/react-router'
|
||||
import type { GitCommandResponse } from '@/types/api'
|
||||
import { FileIcon } from '@/components/FileIcon'
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons'
|
||||
import { CopyIcon, CheckIcon, CloseIcon } from '@/components/icons'
|
||||
import { useAppContext } from '@/lib/app-context'
|
||||
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
|
||||
@@ -14,6 +14,9 @@ import { useTranslation } from '@/lib/use-translation'
|
||||
import { decodeBase64 } from '@/lib/utils'
|
||||
|
||||
const MAX_COPYABLE_FILE_BYTES = 1_000_000
|
||||
const MIN_IMAGE_SCALE = 0.25
|
||||
const MAX_IMAGE_SCALE = 8
|
||||
const IMAGE_SCALE_STEP = 0.25
|
||||
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
|
||||
apng: 'image/apng',
|
||||
avif: 'image/avif',
|
||||
@@ -140,17 +143,257 @@ function extractCommandError(result: GitCommandResponse | undefined): string | n
|
||||
return result.error ?? result.stderr ?? 'Failed to load diff'
|
||||
}
|
||||
|
||||
function clampImageScale(value: number): number {
|
||||
return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value))
|
||||
}
|
||||
|
||||
type ImagePoint = { x: number; y: number }
|
||||
|
||||
function getPointDistance(a: ImagePoint, b: ImagePoint): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y)
|
||||
}
|
||||
|
||||
function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint {
|
||||
return {
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2
|
||||
}
|
||||
}
|
||||
|
||||
function ImagePreview(props: { dataUrl: string; fileName: string; label: string }) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [scale, setScale] = useState(1)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const scaleRef = useRef(scale)
|
||||
const offsetRef = useRef(offset)
|
||||
const activePointersRef = useRef(new Map<number, ImagePoint>())
|
||||
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
|
||||
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null)
|
||||
|
||||
const updateScale = useCallback((next: number | ((current: number) => number)) => {
|
||||
setScale((current) => {
|
||||
const value = typeof next === 'function' ? next(current) : next
|
||||
scaleRef.current = value
|
||||
return value
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateOffset = useCallback((next: ImagePoint) => {
|
||||
offsetRef.current = next
|
||||
setOffset(next)
|
||||
}, [])
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
updateScale(1)
|
||||
updateOffset({ x: 0, y: 0 })
|
||||
}, [updateOffset, updateScale])
|
||||
|
||||
const closeViewer = useCallback(() => {
|
||||
setViewerOpen(false)
|
||||
activePointersRef.current.clear()
|
||||
dragRef.current = null
|
||||
pinchRef.current = null
|
||||
resetView()
|
||||
}, [resetView])
|
||||
|
||||
const zoomBy = useCallback((delta: number) => {
|
||||
updateScale((current) => clampImageScale(current + delta))
|
||||
}, [updateScale])
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP
|
||||
zoomBy(delta)
|
||||
}, [zoomBy])
|
||||
|
||||
const beginPinch = useCallback(() => {
|
||||
const pointers = Array.from(activePointersRef.current.values())
|
||||
if (pointers.length < 2) return
|
||||
|
||||
const [first, second] = pointers
|
||||
pinchRef.current = {
|
||||
startDistance: getPointDistance(first, second),
|
||||
startScale: scaleRef.current,
|
||||
startCenter: getPointCenter(first, second),
|
||||
origin: offsetRef.current
|
||||
}
|
||||
dragRef.current = null
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
|
||||
if (activePointersRef.current.size >= 2) {
|
||||
beginPinch()
|
||||
return
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: offsetRef.current.x,
|
||||
originY: offsetRef.current.y
|
||||
}
|
||||
}, [beginPinch])
|
||||
|
||||
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!activePointersRef.current.has(event.pointerId)) return
|
||||
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
|
||||
if (activePointersRef.current.size >= 2 && pinchRef.current) {
|
||||
const pointers = Array.from(activePointersRef.current.values())
|
||||
const [first, second] = pointers
|
||||
const distance = getPointDistance(first, second)
|
||||
const center = getPointCenter(first, second)
|
||||
const pinch = pinchRef.current
|
||||
const nextScale = pinch.startDistance > 0
|
||||
? clampImageScale(pinch.startScale * (distance / pinch.startDistance))
|
||||
: pinch.startScale
|
||||
|
||||
updateScale(nextScale)
|
||||
updateOffset({
|
||||
x: pinch.origin.x + center.x - pinch.startCenter.x,
|
||||
y: pinch.origin.y + center.y - pinch.startCenter.y
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) return
|
||||
updateOffset({
|
||||
x: drag.originX + event.clientX - drag.startX,
|
||||
y: drag.originY + event.clientY - drag.startY
|
||||
})
|
||||
}, [updateOffset, updateScale])
|
||||
|
||||
const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
|
||||
activePointersRef.current.delete(event.pointerId)
|
||||
if (dragRef.current?.pointerId === event.pointerId) {
|
||||
dragRef.current = null
|
||||
}
|
||||
pinchRef.current = null
|
||||
|
||||
const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined
|
||||
if (remainingPointer) {
|
||||
dragRef.current = {
|
||||
pointerId: remainingPointer[0],
|
||||
startX: remainingPointer[1].x,
|
||||
startY: remainingPointer[1].y,
|
||||
originX: offsetRef.current.x,
|
||||
originY: offsetRef.current.y
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerOpen) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeViewer()
|
||||
}
|
||||
if (event.key === '0') {
|
||||
resetView()
|
||||
}
|
||||
if (event.key === '+' || event.key === '=') {
|
||||
zoomBy(IMAGE_SCALE_STEP)
|
||||
}
|
||||
if (event.key === '-') {
|
||||
zoomBy(-IMAGE_SCALE_STEP)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [closeViewer, resetView, viewerOpen, zoomBy])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[18rem] items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3">
|
||||
<img
|
||||
src={props.dataUrl}
|
||||
alt={props.label}
|
||||
className="max-h-[calc(100vh-14rem)] max-w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<span className="sr-only">{props.fileName}</span>
|
||||
</div>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewerOpen(true)}
|
||||
className="group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left"
|
||||
title="Click to zoom"
|
||||
>
|
||||
<img
|
||||
src={props.dataUrl}
|
||||
alt={props.label}
|
||||
className="max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]"
|
||||
draggable={false}
|
||||
/>
|
||||
<span className="sr-only">{props.fileName}</span>
|
||||
</button>
|
||||
|
||||
{viewerOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.label}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2">
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
|
||||
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
|
||||
disabled={scale <= MIN_IMAGE_SCALE}
|
||||
title="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
|
||||
title="Reset zoom"
|
||||
>
|
||||
{Math.round(scale * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomBy(IMAGE_SCALE_STEP)}
|
||||
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
|
||||
disabled={scale >= MAX_IMAGE_SCALE}
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeViewer}
|
||||
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
|
||||
title="Close"
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
|
||||
onWheel={handleWheel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onDoubleClick={resetView}
|
||||
>
|
||||
<img
|
||||
src={props.dataUrl}
|
||||
alt={props.label}
|
||||
draggable={false}
|
||||
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
|
||||
style={{
|
||||
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user