+
{props.content}
diff --git a/src/renderer/pages/paintings/components/Artboard.tsx b/src/renderer/pages/paintings/components/Artboard.tsx
index 1c75bb0126..57a4a27579 100644
--- a/src/renderer/pages/paintings/components/Artboard.tsx
+++ b/src/renderer/pages/paintings/components/Artboard.tsx
@@ -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 (
+
+
+
+ )
+}
+
const Artboard: FC
= ({ 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(DEFAULT_IMAGE_OFFSET)
+ const [isDraggingImage, setIsDraggingImage] = useState(false)
+ const imageDragRef = useRef(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 = ({ 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) => {
+ 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) => {
+ 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) => {
+ 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 (
@@ -71,33 +191,59 @@ const Artboard: FC
= ({ 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 ? (
- {painting.files.length > 1 && (
-
- )}
-
({ id: file.id, src: FileManager.getFileUrl(file) }))}
+
- {painting.files.length > 1 && (
-
- )}
+
+ {painting.files.length > 1 && (
+ <>
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
= MAX_IMAGE_SCALE}
+ onClick={zoomIn}>
+
+
+
+
+
+
+
+
+
+
+
+
{displayedImageIndex + 1} / {painting.files.length}
diff --git a/src/renderer/pages/paintings/components/__tests__/Artboard.test.tsx b/src/renderer/pages/paintings/components/__tests__/Artboard.test.tsx
new file mode 100644
index 0000000000..5b001413ad
--- /dev/null
+++ b/src/renderer/pages/paintings/components/__tests__/Artboard.test.tsx
@@ -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) => {
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ })
+})
diff --git a/src/renderer/pages/paintings/form/fields/SizeChipsField.tsx b/src/renderer/pages/paintings/form/fields/SizeChipsField.tsx
index 19b69223cf..740e2ee470 100644
--- a/src/renderer/pages/paintings/form/fields/SizeChipsField.tsx
+++ b/src/renderer/pages/paintings/form/fields/SizeChipsField.tsx
@@ -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 (
- {ratio ? : null}
+
)
}
diff --git a/src/renderer/pages/paintings/hooks/__tests__/usePaintingList.test.ts b/src/renderer/pages/paintings/hooks/__tests__/usePaintingList.test.ts
new file mode 100644
index 0000000000..7f29b57ef7
--- /dev/null
+++ b/src/renderer/pages/paintings/hooks/__tests__/usePaintingList.test.ts
@@ -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 {
+ return {
+ id: 'p',
+ providerId: 'silicon',
+ mode: 'generate',
+ prompt: '',
+ files: [],
+ params: {},
+ ...overrides
+ }
+}
+
+function renderList(input: Partial[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()
+ })
+})
diff --git a/src/renderer/pages/paintings/hooks/usePaintingList.ts b/src/renderer/pages/paintings/hooks/usePaintingList.ts
index 68d65cc3e2..dc3135eed1 100644
--- a/src/renderer/pages/paintings/hooks/usePaintingList.ts
+++ b/src/renderer/pages/paintings/hooks/usePaintingList.ts
@@ -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([])
const historyItemsRef = useRef([])
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]
)
diff --git a/src/renderer/pages/paintings/paintingPrimitives.ts b/src/renderer/pages/paintings/paintingPrimitives.ts
index 3919aefecd..e66a94cca2 100644
--- a/src/renderer/pages/paintings/paintingPrimitives.ts
+++ b/src/renderer/pages/paintings/paintingPrimitives.ts
@@ -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