diff --git a/src/renderer/components/AppModal/__tests__/AppModal.test.tsx b/src/renderer/components/AppModal/__tests__/AppModal.test.tsx index d0abe5ebc7..a56ac48b80 100644 --- a/src/renderer/components/AppModal/__tests__/AppModal.test.tsx +++ b/src/renderer/components/AppModal/__tests__/AppModal.test.tsx @@ -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() diff --git a/src/renderer/components/AppModal/index.tsx b/src/renderer/components/AppModal/index.tsx index 2d2791f96c..d9f11284cd 100644 --- a/src/renderer/components/AppModal/index.tsx +++ b/src/renderer/components/AppModal/index.tsx @@ -198,7 +198,11 @@ function AppModalItem({ {props.title ? {props.title} : null} {props.content ? ( -
+
{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