mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user