fix(paintings): improve generated image preview interactions (#16315)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Pleasurecruise <3196812536@qq.com>
Signed-off-by: jd <59188306+zhangjiadi225@users.noreply.github.com>
Signed-off-by: Pleasurecruise <3196812536@qq.com>
This commit is contained in:
jd
2026-06-27 13:59:53 +08:00
committed by GitHub
parent 32e8ef273c
commit ef2f3eec4c
8 changed files with 421 additions and 56 deletions

View File

@@ -23,6 +23,7 @@ vi.mock('@cherrystudio/ui', () => {
DialogContent: ({ children, ...props }) => {
delete props.showCloseButton
delete props.onInteractOutside
delete props.overlayClassName
return React.createElement('div', { role: 'dialog', ...props }, children)
},
@@ -152,6 +153,25 @@ describe('AppModalProvider', () => {
await expect(confirmed!).resolves.toBe(true)
})
it('constrains long plain-text feedback content inside the modal', async () => {
const modal = await renderModalProvider()
const longError =
'Error invoking remote method ai:generate-image: AI_RetryError: Failed after 3 attempts. Last error: request failed: ' +
'https://example.com/' +
'a'.repeat(180)
act(() => {
modal.error({
title: 'Generate failed',
content: longError
})
})
const content = await screen.findByText(longError)
expect(content).toHaveClass('min-w-0', 'max-w-full', 'wrap-anywhere')
})
it('uses translated default text for destructive confirmations', async () => {
const user = userEvent.setup()
const modal = await renderModalProvider()

View File

@@ -198,7 +198,11 @@ function AppModalItem({
{props.title ? <DialogTitle className="text-base leading-6">{props.title}</DialogTitle> : null}
{props.content ? (
<DialogDescription asChild>
<div className={cn('mt-2 text-muted-foreground text-sm leading-5', props.title ? '' : 'mt-0')}>
<div
className={cn(
'wrap-anywhere mt-2 min-w-0 max-w-full text-muted-foreground text-sm leading-5',
props.title ? '' : 'mt-0'
)}>
{props.content}
</div>
</DialogDescription>

View File

@@ -1,20 +1,45 @@
import { Button, ImagePreviewTrigger } from '@cherrystudio/ui'
import { Button, Tooltip } from '@cherrystudio/ui'
import FileManager from '@renderer/services/FileManager'
import { motion } from 'framer-motion'
import { type FC, useCallback, useEffect, useState } from 'react'
import { ImageDown, ImageUp, RefreshCcw, RotateCcwSquare, RotateCwSquare, ZoomIn, ZoomOut } from 'lucide-react'
import {
type FC,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type { PaintingData } from '../model/types/paintingData'
import { paintingClasses } from '../paintingPrimitives'
const DEFAULT_IMAGE_SCALE = 1
const MIN_IMAGE_SCALE = 0.25
const MAX_IMAGE_SCALE = 4
const IMAGE_SCALE_STEP = 0.25
const DEFAULT_IMAGE_OFFSET = { x: 0, y: 0 }
type ImageOffset = typeof DEFAULT_IMAGE_OFFSET
type ImageDragState = {
pointerId: number
x: number
y: number
}
export interface ArtboardProps {
painting: PaintingData
isLoading: boolean
onCancel: () => void
imageCover?: React.ReactNode
loadText?: React.ReactNode
imageCover?: ReactNode
loadText?: ReactNode
}
const LoadingStateCard: FC<{ text: React.ReactNode; onCancel: () => void; cancelLabel: string }> = ({
const LoadingStateCard: FC<{ text: ReactNode; onCancel: () => void; cancelLabel: string }> = ({
text,
onCancel,
cancelLabel
@@ -41,9 +66,36 @@ const LoadingStateCard: FC<{ text: React.ReactNode; onCancel: () => void; cancel
)
}
const ArtboardToolButton: FC<{
children: ReactNode
disabled?: boolean
label: string
onClick: () => void
}> = ({ children, disabled, label, onClick }) => {
return (
<Tooltip content={label} placement="right" delay={800}>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label={label}
onClick={onClick}
className={paintingClasses.toolbarButton}>
{children}
</Button>
</Tooltip>
)
}
const Artboard: FC<ArtboardProps> = ({ painting, isLoading, onCancel, imageCover, loadText }) => {
const { t } = useTranslation()
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [imageScale, setImageScale] = useState(DEFAULT_IMAGE_SCALE)
const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState<ImageOffset>(DEFAULT_IMAGE_OFFSET)
const [isDraggingImage, setIsDraggingImage] = useState(false)
const imageDragRef = useRef<ImageDragState | null>(null)
const displayedImageIndex = painting.files.length > 0 ? Math.min(currentImageIndex, painting.files.length - 1) : 0
const currentFile = painting.files[displayedImageIndex]
// TODO(#15353): swap for `cherrystudio://file/internal/${id}.${ext}` once the
@@ -61,9 +113,77 @@ const Artboard: FC<ArtboardProps> = ({ painting, isLoading, onCancel, imageCover
setCurrentImageIndex((index) => (painting.files.length > 0 ? (index + 1) % painting.files.length : 0))
}, [painting.files.length])
const zoomIn = useCallback(() => {
setImageScale((scale) => Math.min(MAX_IMAGE_SCALE, scale + IMAGE_SCALE_STEP))
}, [])
const zoomOut = useCallback(() => {
setImageScale((scale) => Math.max(MIN_IMAGE_SCALE, scale - IMAGE_SCALE_STEP))
}, [])
const rotateImageRight = useCallback(() => {
setImageRotation((rotation) => rotation + 90)
}, [])
const rotateImageLeft = useCallback(() => {
setImageRotation((rotation) => rotation - 90)
}, [])
const resetImageTransform = useCallback(() => {
imageDragRef.current = null
setIsDraggingImage(false)
setImageScale(DEFAULT_IMAGE_SCALE)
setImageRotation(0)
setImageOffset(DEFAULT_IMAGE_OFFSET)
}, [])
const onImagePointerDown = useCallback((event: PointerEvent<HTMLImageElement>) => {
if (event.button !== 0) {
return
}
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
imageDragRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY
}
setIsDraggingImage(true)
}, [])
const onImagePointerMove = useCallback((event: PointerEvent<HTMLImageElement>) => {
const dragState = imageDragRef.current
if (!dragState || dragState.pointerId !== event.pointerId) {
return
}
event.preventDefault()
const deltaX = event.clientX - dragState.x
const deltaY = event.clientY - dragState.y
dragState.x = event.clientX
dragState.y = event.clientY
setImageOffset((offset) => ({ x: offset.x + deltaX, y: offset.y + deltaY }))
}, [])
const stopImageDrag = useCallback((event: PointerEvent<HTMLImageElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
if (imageDragRef.current?.pointerId === event.pointerId) {
imageDragRef.current = null
setIsDraggingImage(false)
}
}, [])
useEffect(() => {
setCurrentImageIndex(0)
}, [painting.id])
resetImageTransform()
}, [painting.id, resetImageTransform])
useLayoutEffect(() => {
resetImageTransform()
}, [currentFile?.id, resetImageTransform])
return (
<div className="flex min-h-0 w-full flex-1 flex-col p-2">
@@ -71,33 +191,59 @@ const Artboard: FC<ArtboardProps> = ({ painting, isLoading, onCancel, imageCover
className={`relative flex min-h-0 flex-1 flex-col items-center justify-center transition-opacity ${isLoading ? 'opacity-70' : 'opacity-100'}`}>
{painting.files.length > 0 ? (
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
{painting.files.length > 1 && (
<Button
size="icon-sm"
variant="outline"
onClick={onPrevImage}
aria-label={t('preview.previous')}
className="-translate-y-1/2 absolute top-1/2 left-2.5 z-20 opacity-80 hover:opacity-100">
</Button>
)}
<ImagePreviewTrigger
item={{ id: currentFile.id, src: currentImageUrl }}
// TODO(#15353): same custom-protocol switch as `currentImageUrl` above.
items={painting.files.map((file) => ({ id: file.id, src: FileManager.getFileUrl(file) }))}
<img
alt=""
className="max-h-full max-w-full cursor-zoom-in rounded-md bg-secondary object-contain"
className={`max-h-full max-w-full select-none rounded-md bg-secondary object-contain ${
isDraggingImage ? 'cursor-grabbing transition-none' : 'cursor-grab transition-transform duration-150'
}`}
draggable={false}
onPointerCancel={stopImageDrag}
onPointerDown={onImagePointerDown}
onPointerMove={onImagePointerMove}
onPointerUp={stopImageDrag}
src={currentImageUrl}
style={{
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`,
touchAction: 'none'
}}
/>
{painting.files.length > 1 && (
<Button
size="icon-sm"
variant="outline"
onClick={onNextImage}
aria-label={t('preview.next')}
className="-translate-y-1/2 absolute top-1/2 right-2.5 z-20 opacity-80 hover:opacity-100">
</Button>
)}
<div
className={`${paintingClasses.toolbarWrap} ${paintingClasses.toolbarRail}`}
role="toolbar"
aria-label={t('preview.label')}>
{painting.files.length > 1 && (
<>
<ArtboardToolButton label={t('preview.previous')} onClick={onPrevImage}>
<ImageUp className="size-[18px]" />
</ArtboardToolButton>
<ArtboardToolButton label={t('preview.next')} onClick={onNextImage}>
<ImageDown className="size-[18px]" />
</ArtboardToolButton>
<span className="my-0.5 h-px w-4 bg-border-subtle" aria-hidden />
</>
)}
<ArtboardToolButton
label={t('preview.zoom_out')}
disabled={imageScale <= MIN_IMAGE_SCALE}
onClick={zoomOut}>
<ZoomOut className="size-4" />
</ArtboardToolButton>
<ArtboardToolButton
label={t('preview.zoom_in')}
disabled={imageScale >= MAX_IMAGE_SCALE}
onClick={zoomIn}>
<ZoomIn className="size-4" />
</ArtboardToolButton>
<ArtboardToolButton label={t('preview.rotate_left')} onClick={rotateImageLeft}>
<RotateCcwSquare className="size-4" />
</ArtboardToolButton>
<ArtboardToolButton label={t('preview.rotate_right')} onClick={rotateImageRight}>
<RotateCwSquare className="size-4" />
</ArtboardToolButton>
<ArtboardToolButton label={t('preview.reset')} onClick={resetImageTransform}>
<RefreshCcw className="size-4" />
</ArtboardToolButton>
</div>
<div className="-translate-x-1/2 absolute bottom-2.5 left-1/2 rounded-full bg-foreground/60 px-2 py-1 text-background text-xs">
{displayedImageIndex + 1} / {painting.files.length}
</div>

View File

@@ -0,0 +1,112 @@
import type { FileMetadata } from '@renderer/types/file'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { PaintingData } from '../../model/types/paintingData'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
vi.mock('@renderer/services/FileManager', () => ({
default: {
getFileUrl: (file: FileMetadata) => `https://files.test/${file.id}.png`
}
}))
const { default: Artboard } = await import('../Artboard')
const makeFile = (id: string): FileMetadata =>
({
id,
name: `${id}.png`,
origin_name: `${id}.png`,
path: `/tmp/${id}.png`,
size: 100,
ext: '.png',
type: 'image',
created_at: '2026-01-01T00:00:00.000Z',
count: 1
}) as FileMetadata
const makePainting = (): PaintingData =>
({
id: 'painting-1',
providerId: 'openai',
mode: 'generate',
prompt: '',
files: [makeFile('image-1'), makeFile('image-2')]
}) as PaintingData
const firePointer = (element: Element, type: string, init: Record<string, number>) => {
const event = new Event(type, { bubbles: true, cancelable: true })
for (const [key, value] of Object.entries(init)) {
Object.defineProperty(event, key, { value })
}
fireEvent(element, event)
}
describe('Artboard', () => {
beforeAll(() => {
HTMLElement.prototype.setPointerCapture ??= vi.fn()
HTMLElement.prototype.releasePointerCapture ??= vi.fn()
HTMLElement.prototype.hasPointerCapture ??= vi.fn(() => true)
})
it('resets image transform when switching generated images', () => {
render(<Artboard painting={makePainting()} isLoading={false} onCancel={vi.fn()} />)
const image = document.querySelector('img') as HTMLImageElement
fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_in' }))
fireEvent.click(screen.getByRole('button', { name: 'preview.rotate_right' }))
firePointer(image, 'pointerdown', { button: 0, clientX: 10, clientY: 10, pointerId: 1 })
firePointer(image, 'pointermove', { clientX: 35, clientY: 45, pointerId: 1 })
expect(image.style.transform).toBe('translate(25px, 35px) scale(1.25) rotate(90deg)')
fireEvent.click(screen.getByRole('button', { name: 'preview.next' }))
expect(image).toHaveAttribute('src', 'https://files.test/image-2.png')
expect(image.style.transform).toBe('translate(0px, 0px) scale(1) rotate(0deg)')
})
it('ignores non-left-button image drag attempts', () => {
render(<Artboard painting={makePainting()} isLoading={false} onCancel={vi.fn()} />)
const image = document.querySelector('img') as HTMLImageElement
firePointer(image, 'pointerdown', { button: 1, clientX: 10, clientY: 10, pointerId: 1 })
firePointer(image, 'pointermove', { clientX: 35, clientY: 45, pointerId: 1 })
expect(image.style.transform).toBe('translate(0px, 0px) scale(1) rotate(0deg)')
})
it('disables zoom controls at image scale boundaries', () => {
render(<Artboard painting={makePainting()} isLoading={false} onCancel={vi.fn()} />)
const image = document.querySelector('img') as HTMLImageElement
const zoomInButton = screen.getByRole('button', { name: 'preview.zoom_in' })
const zoomOutButton = screen.getByRole('button', { name: 'preview.zoom_out' })
expect(zoomOutButton).not.toBeDisabled()
for (let i = 0; i < 3; i++) {
fireEvent.click(zoomOutButton)
}
expect(image.style.transform).toBe('translate(0px, 0px) scale(0.25) rotate(0deg)')
expect(zoomInButton).not.toBeDisabled()
expect(zoomOutButton).toBeDisabled()
for (let i = 0; i < 15; i++) {
fireEvent.click(zoomInButton)
}
expect(image.style.transform).toBe('translate(0px, 0px) scale(4) rotate(0deg)')
expect(zoomInButton).toBeDisabled()
expect(zoomOutButton).not.toBeDisabled()
})
})

View File

@@ -9,7 +9,7 @@ const MIN_THUMB = 6
const DEFAULT_COLUMNS = 3
const chipClass = {
base: 'flex min-h-10 min-w-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-[10px] px-2 py-1 text-[11px] leading-tight transition-all',
base: 'flex min-h-10 min-w-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-[10px] px-1 py-1 text-[11px] leading-tight transition-all',
active: 'bg-secondary-active text-foreground ring-1 ring-[var(--color-border-active)]',
inactive: 'bg-muted text-muted-foreground/60 hover:bg-secondary-hover hover:text-foreground',
disabled: 'cursor-not-allowed opacity-50'
@@ -90,9 +90,12 @@ function RatioShape({ ratio, selected }: { ratio: Dim; selected: boolean }) {
function RatioThumb({ value, selected }: { value: string; selected: boolean }) {
const ratio = parseRatio(value)
// Resolution tiers (`1K`/`2K`/`4K`) have no aspect ratio — render the label
// alone, centered, instead of reserving an empty thumb slot above it.
if (!ratio) return null
return (
<span className="flex shrink-0 items-center justify-center" style={{ width: MAX_THUMB, height: MAX_THUMB }}>
{ratio ? <RatioShape ratio={ratio} selected={selected} /> : null}
<RatioShape ratio={ratio} selected={selected} />
</span>
)
}

View File

@@ -0,0 +1,91 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PaintingData } from '../../model/types/paintingData'
import { usePaintingList } from '../usePaintingList'
const { createPainting, updatePainting, deletePainting, refresh } = vi.hoisted(() => ({
createPainting: vi.fn(),
updatePainting: vi.fn(),
deletePainting: vi.fn(),
refresh: vi.fn()
}))
vi.mock('@renderer/hooks/usePaintings', () => ({
usePaintings: () => ({
records: [],
total: 0,
isLoading: false,
refresh,
createPainting,
updatePainting,
deletePainting,
reorderPaintings: vi.fn()
})
}))
function makePainting(overrides: Partial<PaintingData>): PaintingData {
return {
id: 'p',
providerId: 'silicon',
mode: 'generate',
prompt: '',
files: [],
params: {},
...overrides
}
}
function renderList(input: Partial<Parameters<typeof usePaintingList>[0]>) {
const setCurrentPainting = vi.fn()
const cancelGeneration = vi.fn()
const result = renderHook(() =>
usePaintingList({
painting: makePainting({ id: 'current', persistedAt: '2026-01-01T00:00:00.000Z' }),
setCurrentPainting,
currentProviderId: 'silicon',
modelOptions: [],
historyItems: [],
cancelGeneration,
...input
})
)
return { ...result, setCurrentPainting, cancelGeneration }
}
describe('usePaintingList', () => {
beforeEach(() => {
vi.clearAllMocks()
deletePainting.mockResolvedValue(undefined)
refresh.mockResolvedValue(undefined)
})
it('add() seeds a fresh in-memory draft without persisting it', () => {
const { result, setCurrentPainting } = renderList({})
act(() => {
result.current.add()
})
expect(setCurrentPainting).toHaveBeenCalledTimes(1)
const draft = setCurrentPainting.mock.calls[0][0] as PaintingData
expect(draft).toMatchObject({ providerId: 'silicon', mode: 'generate', prompt: '', files: [] })
// The whole point of the fix: a blank draft must NOT hit the DB / strip on click.
expect(draft.persistedAt).toBeUndefined()
expect(createPainting).not.toHaveBeenCalled()
})
it('remove() deletes the record then refreshes the strip', async () => {
const target = makePainting({ id: 'other', persistedAt: '2026-01-01T00:00:00.000Z' })
const { result, setCurrentPainting, cancelGeneration } = renderList({})
await act(async () => {
await result.current.remove(target)
})
expect(cancelGeneration).toHaveBeenCalledWith('other')
expect(deletePainting).toHaveBeenCalledWith('other')
expect(refresh).toHaveBeenCalledTimes(1)
expect(setCurrentPainting).not.toHaveBeenCalled()
})
})

View File

@@ -1,12 +1,9 @@
import { loggerService } from '@logger'
import { usePaintings } from '@renderer/hooks/usePaintings'
import type { PaintingMode } from '@shared/data/types/painting'
import { useCallback, useRef } from 'react'
import { presentPaintingGenerateError } from '../errors/paintingGenerateError'
import { paintingDataToCreateDto } from '../model/mappers/paintingDataToCreateDto'
import { paintingDataToUpdateDto } from '../model/mappers/paintingDataToUpdateDto'
import { recordToPaintingData } from '../model/mappers/recordToPaintingData'
import { createDefaultPainting } from '../model/paintingPipeline'
import type { PaintingData } from '../model/types/paintingData'
import type { ModelOption } from '../model/types/paintingModel'
@@ -25,8 +22,10 @@ interface UsePaintingListInput {
/**
* Owns the painting list-item write-side lifecycle: add / remove.
*
* - `add()` seeds a fresh draft using the current provider definition (and the
* latest model options if any), then persists it via DataApi.
* - `add()` seeds a fresh in-memory draft on the current provider. It is NOT
* persisted — like the page's mount-time draft, it only reaches DataApi when
* the user generates (`usePaintingGeneration` creates the row for an unsaved
* draft). This keeps blank paintings from piling up in the strip on every click.
* - `remove(painting)` cancels any in-flight generation, deletes attached files,
* removes the DB record, and (if the deleted item is the current one) selects
* the next available painting or falls back to a fresh draft via `add()`.
@@ -42,7 +41,7 @@ export function usePaintingList({
historyItems,
cancelGeneration
}: UsePaintingListInput) {
const { createPainting, updatePainting, deletePainting, refresh } = usePaintings()
const { updatePainting, deletePainting, refresh } = usePaintings()
const modelOptionsRef = useRef<ModelOption[]>([])
const historyItemsRef = useRef<PaintingData[]>([])
const paintingRef = useRef(painting)
@@ -75,19 +74,9 @@ export function usePaintingList({
[saveCurrent, setCurrentPainting]
)
const add = useCallback(async () => {
const nextPainting = createDefaultPainting(currentProviderId)
setCurrentPainting(nextPainting)
try {
const createdRecord = await createPainting(
paintingDataToCreateDto(nextPainting as PaintingData & { providerId: string; mode: PaintingMode })
)
setCurrentPainting(await recordToPaintingData(createdRecord))
} catch (error) {
presentPaintingGenerateError(error)
}
}, [createPainting, currentProviderId, setCurrentPainting])
const add = useCallback(() => {
setCurrentPainting(createDefaultPainting(currentProviderId))
}, [currentProviderId, setCurrentPainting])
const selectNextAfterDelete = useCallback(
async (deletedId: string) => {
@@ -104,7 +93,7 @@ export function usePaintingList({
setCurrentPainting(nextPainting)
return
}
await add()
add()
},
[add, refresh, setCurrentPainting]
)

View File

@@ -25,9 +25,9 @@ export const paintingClasses = {
promptModeTabsTrigger:
'h-7 rounded-full px-2.5 text-xs text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
promptWrap: 'shrink-0 px-2 pb-4 pt-2',
toolbarWrap: 'absolute top-4 left-4 z-20',
toolbarWrap: 'absolute top-1/2 left-4 z-20 -translate-y-1/2',
toolbarRail:
'flex items-center rounded-full border border-border-muted bg-background/90 p-1 shadow-md backdrop-blur-xl',
'flex flex-col items-center gap-1 rounded-full border border-border-muted bg-background/90 p-1 shadow-md backdrop-blur-xl',
toolbarButton: 'rounded-full text-muted-foreground hover:bg-muted/55 hover:text-foreground',
toolbarButtonActive: 'bg-muted text-foreground'
} as const