diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx index c4e337fd7fd..5b6afb8f116 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx @@ -107,6 +107,8 @@ To add one, click **Add reference image** and pick images from the Gallery or up Hovering over a reference's thumbnail offers **Crop**, to use only part of the image, and **Use size**, to set the generation's size to match the reference. The switch on each card turns a reference off without removing it. +In the crop dialog, drag the crop box or its handles, or pick an **Aspect ratio** (16:9, 3:2, 4:3, 1:1, 3:4, 2:3 or 9:16) to keep the box in proportion while you resize it. Scroll to zoom in on the image, hold **Space** (or use the middle mouse button) and drag to move the view, and use the **Fit image**, **Actual size**, **Zoom in** and **Zoom out** buttons. **Apply** uses the crop for the reference; **Save to Uploads** also stores the cropped image as an upload in the Gallery without changing the reference. + What a reference does, and what options it has, depends on the model family: | Model family | How references work | Options | diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index 8d954d225e8..b20c1f209f5 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx @@ -89,7 +89,7 @@ The gear button in the Gallery header opens its settings: * **Show in-progress section** — shows generations that are still running at the top of the grid. * **Pagination** — **Infinite** scrolling or numbered **Pages**. -**All Gallery settings…** at the bottom opens the full settings. They also include **Send new results to the selected board** (under **Destination**), which is on by default; turn it off to keep sending results to the current destination board, which then becomes the **Auto** board. The full settings also include the sort order and the board visibility and sorting options from the [Filter & sort boards](/users-guide/gallery/introduction/#finding-filtering-and-sorting-boards) menu. All Gallery settings are saved with the project. +**All Gallery settings…** at the bottom opens the full settings. They also include **Middle-click opens media in a new tab** (under **Thumbnails**), off by default, which makes a middle click on an image or video tile open it in a new browser tab, and **Send new results to the selected board** (under **Destination**), which is on by default; turn it off to keep sending results to the current destination board, which then becomes the **Auto** board. The full settings also include the sort order and the board visibility and sorting options from the [Filter & sort boards](/users-guide/gallery/introduction/#finding-filtering-and-sorting-boards) menu. All Gallery settings are saved with the project. ## Keyboard shortcuts diff --git a/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx b/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx index 01246c552ef..ceeb399f26b 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx @@ -38,7 +38,7 @@ The Shape and Select buttons hold several tools. Click to use the current one, o ### Tool options * **Brush** — **Size**, **Opacity** and **Hardness**; the **Foreground** color; and, under **Dynamics**, whether pen pressure affects the width or the opacity. **[** and **]** make the brush smaller or larger, and so does **Ctrl**+scroll. -* **Eraser** — **Size**, **Opacity** and **Hardness**. +* **Eraser** — **Size**, **Opacity** and **Hardness**; and, under **Dynamics**, whether pen pressure affects the width. Pressure never changes how strongly the eraser erases. Eraser settings are temporary and are not saved in the project; they can reset when the canvas is released or the page reloads. * **Shape** — the **Kind** of shape; **Place on**, which draws on the **Selected layer** or on a **New layer** of its own; **Fill** and **Stroke**, each with a color; and the stroke **Width**. **Shift** constrains the proportions. For polygons, click to place points and double-click, press **Enter** or click the first point to close the shape. With an inpaint mask or regional guidance layer selected, the shape is added to the mask or region, which makes shapes a quick way to mark rectangular or geometric areas; the colors are ignored. * **Gradient** — **Kind** (Linear or Radial), **Angle**, and the color **Stops**, which can be added and removed or follow the foreground and background colors. Dragging creates a gradient layer the size of the generation frame, or repositions the selected gradient layer. * **Text** — the font **Family**, **Style** or **Weight**, **Size**, **Line height**, **Align** and **Fill** color. Click the canvas to start typing; **Ctrl+Enter** or clicking elsewhere finishes the text, and **Escape** cancels it. The text stays editable as a text layer. @@ -212,7 +212,8 @@ These shortcuts work while the canvas has focus. On macOS, use **Cmd** in place | **Ctrl+J** / **Ctrl+E** | Duplicate layer / merge layer down | | **Ctrl+G** / **Ctrl+Shift+G** | Group / ungroup layers | | **Ctrl+]** / **Ctrl+[** (add **Shift** for front / back) | Move layer forward / backward | -| **Alt+[** / **Alt+]** | Previous / next layer, or staged result | +| **Alt+[** / **Alt+]** | Previous / next layer (wrapping, in the order of the Layers panel), or staged result while results are staged | +| **Q** | Switch to the layer bookmarked with **Bookmark for quick switch** in its menu, or back to the previously selected layer | | **Shift+H** | Hide control, guidance and mask layers | | **Shift+N** / **Shift+B** | Fit the generation frame to the layers / to the masks | | **Enter** / **Escape** | Apply / cancel a transform, text or filter | diff --git a/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx b/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx index d9805e7dd32..bb27657b9b0 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx @@ -82,7 +82,7 @@ The icons at the top **Arrange** the layer (move it to the front, forward, backw * **Merge down**. * **Copy to** — including **Copy layer to clipboard**, and copies to other layer types. * **Crop layer to bbox** and **Save layer to uploads** (save it to the Gallery's Uploads). - * **Disable layer**, **Lock layer** and **Color label**. + * **Disable layer**, **Lock layer**, **Bookmark for quick switch** and **Color label**. A bookmarked layer is where **Q** switches on the canvas; press **Q** again to return. The bookmark is temporary and is not saved in the project. It can reset when the canvas is released, for example after switching away from its project, and resets on reload. * **Delete layer**. * **Raster layers:** * **Transform**. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 2b8c859f965..ebc319488dd 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2383,8 +2383,15 @@ "copyValue": "Copy value", "couldNotExpandPrompt": "Could not expand the prompt.", "couldNotGeneratePromptFromImage": "Could not generate a prompt from the selected image.", + "cropActualSize": "Actual size", + "cropAspectRatioFree": "Free", + "cropFitView": "Fit image", "cropReferenceImage": "Crop reference image", - "cropReferenceImageHelp": "Drag the crop box to move it, or drag its handles to resize. Apply to create a cropped reference image.", + "cropReferenceImageHelp": "Drag the crop box to move it, or drag its handles to resize. Scroll to zoom and hold Space to drag the view. Apply to create a cropped reference image.", + "cropSaveToAssets": "Save to Uploads", + "cropSavedToAssets": "The crop was saved to Uploads.", + "cropZoomIn": "Zoom in", + "cropZoomOut": "Zoom out", "deletePresetNamed": "Delete preset {{name}}", "deletePromptHistoryItem": "Delete prompt history item", "denoisingStrength": "Denoising strength", @@ -3115,6 +3122,7 @@ "nudgeUpLarge": "Nudge layer up 10px", "pasteImage": "Paste image", "previousEntity": "Previous canvas entity", + "quickSwitch": "Quick switch to bookmarked or previous layer", "redo": "Redo canvas edit", "reorderLayer": "Reorder layer", "resetSelected": "Clear selected mask", @@ -3577,6 +3585,7 @@ "addRegionalReferenceImage": "Regional reference image", "actionFailed": "Layer action failed", "blendMode": "Blend mode", + "bookmarkForQuickSwitch": "Bookmark for quick switch", "busy": "The canvas is busy. Try again when the current action finishes.", "copyFailed": "The layer could not be copied.", "copyLayerToClipboard": "Copy layer to clipboard", @@ -3635,6 +3644,7 @@ "overBudget": "There is not enough canvas memory to make this change undoable, so nothing was changed.", "stale": "The layer changed before this action finished, so nothing was changed. Try again.", "rasterize": "Rasterize layer", + "removeQuickSwitchBookmark": "Remove quick switch bookmark", "rename": "Rename layer", "reorder": "Reorder layer", "runWorkflow": "Run workflow", @@ -4301,6 +4311,7 @@ "imageSize": "Image size", "imageSort": "Image Sort", "infinite": "Infinite", + "middleClickOpensInNewTab": "Middle-click opens media in a new tab", "itemActions": { "loadWorkflow": { "editorUnavailable": "The workflow editor is not available in this layout.", diff --git a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx index 60c649dcb38..6bc339aad57 100644 --- a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx @@ -3,7 +3,7 @@ import type { QueueItem, QueueItemReadModel } from '@features/queue/contracts'; import type { ReadableExternalStore } from '@platform/state/projectedExternalStore'; import type { ReactNode } from 'react'; -import { getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; +import { getGalleryAutoAddBoardId, getSelectedGalleryImageFromValues } from '@features/gallery/contracts'; import { invalidateGallery } from '@features/gallery/queries'; import { galleryImageUrls } from '@features/gallery/utility'; import { GenerationUiProvider } from '@features/generation/react'; @@ -232,13 +232,17 @@ export const GenerationUiAdapterProvider = ({ children }: { children: ReactNode (imageName) => findGalleryItem({ kind: 'image', name: imageName }), [findGalleryItem] ); + const uploadBoardId = useActiveProjectSelector( + (activeProject) => getGalleryAutoAddBoardId(getProjectWidgetValues(activeProject, 'gallery')) ?? 'none' + ); const galleryGroup = useMemo( () => ({ findImage, selectedImage: selectedGalleryImage, touchImages: () => void invalidateGallery(queryClient), + uploadBoardId, }), - [findImage, queryClient, selectedGalleryImage] + [findImage, queryClient, selectedGalleryImage, uploadBoardId] ); const openInModelManager = useOpenModelInManager(); const modelsGroup = useMemo( diff --git a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts index 1abc29a150e..4e1bfd706bd 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts @@ -35,6 +35,8 @@ export interface GallerySettings { collapsedBoardSections: GalleryBoardSectionId[]; imageDensityPercent: number; imageOrderDir: GalleryOrderDir; + /** Opt-in: a middle click on a tile opens the media in a new browser tab instead of the browser default. */ + middleClickOpensInNewTab: boolean; paginationMode: GalleryPaginationMode; showArchivedBoards: boolean; showDateBoards: boolean; @@ -58,6 +60,7 @@ export const DEFAULT_GALLERY_SETTINGS: GallerySettings = { collapsedBoardSections: [], imageDensityPercent: 50, imageOrderDir: 'DESC', + middleClickOpensInNewTab: false, paginationMode: 'infinite', showArchivedBoards: false, showDateBoards: false, @@ -122,6 +125,7 @@ export const getGallerySettings = (values: Record): GallerySett ? Math.min(100, Math.max(0, values.imageDensityPercent)) : DEFAULT_GALLERY_SETTINGS.imageDensityPercent, imageOrderDir: isOrderDir(values.imageOrderDir) ? values.imageOrderDir : DEFAULT_GALLERY_SETTINGS.imageOrderDir, + middleClickOpensInNewTab: values.middleClickOpensInNewTab === true, paginationMode, showArchivedBoards: typeof values.showArchivedBoards === 'boolean' diff --git a/invokeai/frontend/webv2/src/features/gallery/settingsContribution.ts b/invokeai/frontend/webv2/src/features/gallery/settingsContribution.ts index 6a46be869ac..bec4491062c 100644 --- a/invokeai/frontend/webv2/src/features/gallery/settingsContribution.ts +++ b/invokeai/frontend/webv2/src/features/gallery/settingsContribution.ts @@ -42,6 +42,14 @@ export const gallerySettingsContribution: SettingsContribution = { keywords: 'queue progress placeholders', scope: 'instance', }, + { + id: 'middleClickOpensInNewTab', + kind: 'boolean', + label: (t) => t('widgets.gallery.middleClickOpensInNewTab'), + group: (t) => t('widgets.gallery.settingsThumbnails'), + keywords: 'mouse middle click wheel button open new tab', + scope: 'instance', + }, { id: 'paginationMode', kind: 'select', diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx index 1780ed10392..1257dd51fe0 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx @@ -1227,6 +1227,45 @@ describe('GalleryImageGrid mixed item cells', () => { expect(imageActionMocks.openItemInPreview).not.toHaveBeenCalled(); }); + describe.each([ + { item: createItem('image', 'last.png'), label: 'Select last.png for preview' }, + { item: createItem('video', 'shared'), label: 'Select video shared, duration 1:06, for preview' }, + ])('browser middle-click on $item.kind', ({ item, label }) => { + it.each([false, true])( + 'respects middleClickOpensInNewTab=%s without selecting, previewing, or dragging', + async (enabled) => { + const gallery = createGallery({ items: [item], selectedItemKey: null, selectedItemKeys: [] }); + await renderGallery({ ...gallery, settings: { ...gallery.settings, middleClickOpensInNewTab: enabled } }); + const button = getButton(label); + const events: MouseEvent[] = []; + const recordEvent = (event: MouseEvent) => events.push(event); + // Keep the native events until React has handled them, even if it stops their propagation. + button.addEventListener('mousedown', recordEvent, { capture: true, once: true }); + button.addEventListener('auxclick', recordEvent, { capture: true, once: true }); + + await act(() => userEvent.click(button, { button: 'middle' })); + + expect( + events.map(({ type, button, isTrusted, defaultPrevented }) => ({ type, button, isTrusted, defaultPrevented })) + ).toEqual([ + { type: 'mousedown', button: 1, isTrusted: true, defaultPrevented: enabled }, + { type: 'auxclick', button: 1, isTrusted: true, defaultPrevented: enabled }, + ]); + if (enabled) { + expect(imageActionMocks.openItemInNewTab).toHaveBeenCalledExactlyOnceWith(item); + } else { + expect(imageActionMocks.openItemInNewTab).not.toHaveBeenCalled(); + } + expect(actionMocks.selectItem).not.toHaveBeenCalled(); + expect(actionMocks.selectItemRange).not.toHaveBeenCalled(); + expect(actionMocks.toggleItemInSelection).not.toHaveBeenCalled(); + expect(actionMocks.setCompareItem).not.toHaveBeenCalled(); + expect(imageActionMocks.openItemInPreview).not.toHaveBeenCalled(); + expect(onDragStart).not.toHaveBeenCalled(); + } + ); + }); + it('toggles the star on each click of a double-click without selecting or opening the item', async () => { await renderGallery(); await act(() => userEvent.dblClick(getButton('Star first.png'))); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx index 584933ad960..ceb8fe8f842 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.tsx @@ -431,6 +431,7 @@ export const GalleryImageGrid = () => { const viewportRef = useRef(null); const { imageDensityPercent, + middleClickOpensInNewTab, paginationMode, progressSectionCollapsed, showImageDimensions, @@ -1654,6 +1655,7 @@ export const GalleryImageGrid = () => { [itemActions] ); const handleOpenItem = useCallback((item: GalleryItem) => itemActions.openItemInPreview(item), [itemActions]); + const handleOpenItemInNewTab = useCallback((item: GalleryItem) => itemActions.openItemInNewTab(item), [itemActions]); // Releasing the anchor puts the window back over the top of the listing. const handleReturnToBoardTop = useCallback(() => galleryCommands.setPage(0), [galleryCommands]); @@ -1690,6 +1692,7 @@ export const GalleryImageGrid = () => { onContextMenu={handleThumbnailContextMenu} onFocusLost={restoreTileFocus} onOpen={handleOpenItem} + onOpenInNewTab={middleClickOpensInNewTab ? handleOpenItemInNewTab : null} onToggleStarred={handleToggleStarred} selectionPage={selectionPage} /> @@ -1701,11 +1704,13 @@ export const GalleryImageGrid = () => { getDragItems, getReadyItemLabel, handleOpenItem, + handleOpenItemInNewTab, handleThumbnailClick, handleThumbnailContextMenu, handleToggleStarred, isComparisonActive, isFollowingLive, + middleClickOpensInNewTab, region, restoreTileFocus, selectedItemKeys, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.browser.test.tsx index e15a662b097..30970860e22 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.browser.test.tsx @@ -26,6 +26,7 @@ let host: HTMLDivElement | null = null; let root: Root | null = null; const STALE_TARGET = { projectId: 'old-project' }; const PICKED_BOARD_VALUES = { selectedBoardId: 'picked' }; +const NO_VALUES = {}; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const Harness = ({ @@ -105,6 +106,18 @@ describe('Gallery settings contribution bindings', () => { expect(host?.querySelector('output')?.textContent).toContain('"showPendingItems":false'); }); + it('binds the opt-in middle-click setting, off by default, through the settings command', async () => { + await act(() => root?.render()); + const quick = host?.querySelector('[data-surface="quick"] input'); + const dialog = host?.querySelector('[data-surface="dialog"] input'); + + expect(quick?.checked).toBe(false); + await act(() => dialog?.click()); + + expect(quick?.checked).toBe(true); + expect(host?.querySelector('output')?.textContent).toContain('"middleClickOpensInNewTab":true'); + }); + it('pins the current destination when results stop following the selected board', async () => { await act(() => root?.render()); const toggle = host?.querySelector('[data-surface="quick"] input'); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.tsx index 6973f020fe4..2f728ae05c8 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySettingField.tsx @@ -38,6 +38,7 @@ export const GallerySettingField = ({ field, surface, target }: SettingFieldProp } else if ( (field.id === 'showImageDimensions' || field.id === 'showPendingItems' || + field.id === 'middleClickOpensInNewTab' || field.id === 'showDateBoards' || field.id === 'showArchivedBoards' || field.id === 'showOtherProjectBoards') && diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx index e1fb1d5facb..346494d7944 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx @@ -87,6 +87,7 @@ const GalleryThumbnail = ({ onContextMenu, onFocusLost, onOpen, + onOpenInNewTab, onToggleStarred, }: { alwaysShowDimensions: boolean; @@ -108,6 +109,8 @@ const GalleryThumbnail = ({ onFocusLost: (itemKey: GalleryItemKey) => void; /** Double-click or Enter: show the item in the centre Preview. */ onOpen: (item: GalleryItem) => void; + /** Middle click, when the Gallery setting opts in; null leaves the browser's middle-click behaviour alone. */ + onOpenInNewTab: ((item: GalleryItem) => void) | null; onToggleStarred: (item: GalleryItem) => void; }) => { const { t } = useTranslation(); @@ -232,6 +235,27 @@ const GalleryThumbnail = ({ [item, onOpen] ); + // Middle button only; the primary button's click/double-click stay selection and open. Its mousedown default + // (autoscroll on some platforms) is suppressed so the release reliably delivers the auxclick. + const handleMiddleMouseDown = useCallback( + (event: MouseEvent) => { + if (onOpenInNewTab && event.button === 1) { + event.preventDefault(); + } + }, + [onOpenInNewTab] + ); + const handleAuxClick = useCallback( + (event: MouseEvent) => { + if (onOpenInNewTab && event.button === 1) { + event.preventDefault(); + event.stopPropagation(); + onOpenInNewTab(item); + } + }, + [item, onOpenInNewTab] + ); + // Enter opens and Space selects; neither starts a keyboard drag. Enter's default would click (select) first. const handleActivationKeyDown = useCallback( (event: KeyboardEvent) => { @@ -301,9 +325,11 @@ const GalleryThumbnail = ({ style={THUMBNAIL_BUTTON_STYLE} tabIndex={isTabStop ? 0 : -1} type="button" + onAuxClick={handleAuxClick} onClick={handleClick} onDoubleClick={handleDoubleClick} onKeyDown={handleActivationKeyDown} + onMouseDown={handleMiddleMouseDown} > {item.name} { collapsedBoardSections: [], imageDensityPercent: 50, imageOrderDir: 'DESC', + middleClickOpensInNewTab: false, paginationMode: 'infinite', showArchivedBoards: false, showDateBoards: false, diff --git a/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts b/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts index 0d05093a2b3..1b31983447f 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts @@ -183,3 +183,212 @@ export const applyReferenceImageCrop = ( export const clearReferenceImageCrop = (image: CroppableImageWithDims): CroppableImageWithDims => ({ original: { image: cloneImageWithDims(image.original.image) }, }); + +// ---- Crop-box geometry for the reference image crop editor ------------------------------------------------------- +// Boxes are percentages of the original image, so an aspect ratio (width / height in pixels) becomes a width% per +// height% factor through the image's own proportions. + +export type ReferenceCropAspectRatioId = 'free' | '16:9' | '3:2' | '4:3' | '1:1' | '3:4' | '2:3' | '9:16'; + +/** The legacy cropper's presets, in its order; `null` leaves the box free. */ +export const REFERENCE_CROP_ASPECT_RATIOS: readonly { id: ReferenceCropAspectRatioId; ratio: number | null }[] = [ + { id: 'free', ratio: null }, + { id: '16:9', ratio: 16 / 9 }, + { id: '3:2', ratio: 3 / 2 }, + { id: '4:3', ratio: 4 / 3 }, + { id: '1:1', ratio: 1 }, + { id: '3:4', ratio: 3 / 4 }, + { id: '2:3', ratio: 2 / 3 }, + { id: '9:16', ratio: 9 / 16 }, +]; + +export type ReferenceCropHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +export type ReferenceCropDragMode = 'move' | ReferenceCropHandle; + +export interface ReferenceCropDrag { + /** The box when the drag started. */ + box: ReferenceImageCropBoxPct; + mode: ReferenceCropDragMode; + /** The pointer position when the drag started, in image percent. */ + x: number; + y: number; +} + +export interface ReferenceCropConstraints { + /** The original image size, which maps pixel ratios onto percent boxes. */ + image: { height: number; width: number }; + /** Minimum crop edge, in image pixels. */ + minDimensionPx: number; + /** Pixel width / height to hold, or null for a free box. */ + ratio: number | null; +} + +/** Tolerance when matching a box to a preset: a percent box rounded to pixels is never exact. */ +const RATIO_MATCH_TOLERANCE = 0.005; + +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +const minSizePct = ({ image, minDimensionPx }: ReferenceCropConstraints): { height: number; width: number } => ({ + height: Math.min(100, (minDimensionPx / Math.max(1, image.height)) * 100), + width: Math.min(100, (minDimensionPx / Math.max(1, image.width)) * 100), +}); + +/** Width percent per height percent for a pixel ratio on this image. */ +const toPctRatio = (ratio: number, image: ReferenceCropConstraints['image']): number => + (ratio * Math.max(1, image.height)) / Math.max(1, image.width); + +/** The preset whose pixel ratio the box has, or `free`. */ +export const matchReferenceCropAspectRatio = ( + box: ReferenceImageCropBoxPct, + image: ReferenceCropConstraints['image'] +): ReferenceCropAspectRatioId => { + const pixelRatio = (box.width * image.width) / Math.max(1e-6, box.height * image.height); + const match = REFERENCE_CROP_ASPECT_RATIOS.find( + ({ ratio }) => ratio !== null && Math.abs(pixelRatio / ratio - 1) <= RATIO_MATCH_TOLERANCE + ); + return match?.id ?? 'free'; +}; + +/** + * A box of `pctRatio` whose width is at most `width`, at least the minimum edge, and that fits the image: the larger + * of the minimum edges wins, and overflow shrinks the box rather than breaking the ratio. + */ +const fitRatioBox = ( + width: number, + pctRatio: number, + constraints: ReferenceCropConstraints +): { height: number; width: number } => { + const min = minSizePct(constraints); + let w = Math.max(width, min.width, min.height * pctRatio); + w = Math.min(w, 100, 100 * pctRatio); + return { height: w / pctRatio, width: w }; +}; + +/** Re-centers the box on `(cx, cy)` and slides it back inside the image when the center sits near an edge. */ +const placeAround = (cx: number, cy: number, size: { height: number; width: number }): ReferenceImageCropBoxPct => ({ + height: size.height, + width: size.width, + x: clamp(cx - size.width / 2, 0, 100 - size.width), + y: clamp(cy - size.height / 2, 0, 100 - size.height), +}); + +/** + * Applies a preset to an existing box: the area and center are preserved where the image allows, as the legacy + * cropper did, so switching presets reshapes rather than relocates the crop. A null ratio returns the box as is. + */ +export const constrainReferenceCropBox = ( + box: ReferenceImageCropBoxPct, + constraints: ReferenceCropConstraints +): ReferenceImageCropBoxPct => { + if (constraints.ratio === null) { + return box; + } + const pctRatio = toPctRatio(constraints.ratio, constraints.image); + const size = fitRatioBox(Math.sqrt(box.width * box.height * pctRatio), pctRatio, constraints); + return placeAround(box.x + box.width / 2, box.y + box.height / 2, size); +}; + +const resolveFreeDrag = ( + drag: ReferenceCropDrag, + point: { x: number; y: number }, + constraints: ReferenceCropConstraints +): ReferenceImageCropBoxPct => { + const dx = point.x - drag.x; + const dy = point.y - drag.y; + const { box, mode } = drag; + const min = minSizePct(constraints); + let left = box.x; + let top = box.y; + let right = box.x + box.width; + let bottom = box.y + box.height; + + if (mode.includes('w')) { + left = clamp(left + dx, 0, right - min.width); + } + if (mode.includes('e')) { + right = clamp(right + dx, left + min.width, 100); + } + if (mode.includes('n')) { + top = clamp(top + dy, 0, bottom - min.height); + } + if (mode.includes('s')) { + bottom = clamp(bottom + dy, top + min.height, 100); + } + return { height: bottom - top, width: right - left, x: left, y: top }; +}; + +/** + * A ratio-locked handle drag. Corners anchor the opposite corner and follow the larger ratio-normalized displacement; + * edges anchor the opposite edge, centering the other dimension until an image edge requires it to slide. + */ +const resolveRatioDrag = ( + drag: ReferenceCropDrag, + point: { x: number; y: number }, + constraints: ReferenceCropConstraints, + ratio: number +): ReferenceImageCropBoxPct => { + const { box, mode } = drag; + const pctRatio = toPctRatio(ratio, constraints.image); + const free = resolveFreeDrag(drag, point, constraints); + const anchorLeft = mode.includes('w') ? box.x + box.width : box.x; + const anchorTop = mode.includes('n') ? box.y + box.height : box.y; + const horizontal = mode === 'e' || mode === 'w'; + const vertical = mode === 'n' || mode === 's'; + + // The room between the anchor and the image edge on the growing side. + const roomWidth = mode.includes('w') ? anchorLeft : 100 - anchorLeft; + const roomHeight = mode.includes('n') ? anchorTop : 100 - anchorTop; + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + + let width: number; + if (vertical) { + width = free.height * pctRatio; + } else if (horizontal) { + width = free.width; + } else { + width = + Math.abs(free.width - box.width) >= Math.abs(free.height - box.height) * pctRatio + ? free.width + : free.height * pctRatio; + } + let size = fitRatioBox(width, pctRatio, constraints); + // Side handles may use the full orthogonal image dimension; placement below slides their center if needed. + const maxWidth = horizontal + ? Math.min(roomWidth, 100 * pctRatio) + : vertical + ? Math.min(roomHeight * pctRatio, 100) + : Math.min(roomWidth, roomHeight * pctRatio); + if (size.width > maxWidth) { + size = fitRatioBox(maxWidth, pctRatio, constraints); + } + + const x = horizontal || !vertical ? (mode.includes('w') ? anchorLeft - size.width : anchorLeft) : cx - size.width / 2; + const y = vertical || !horizontal ? (mode.includes('n') ? anchorTop - size.height : anchorTop) : cy - size.height / 2; + return { + height: size.height, + width: size.width, + x: clamp(x, 0, 100 - size.width), + y: clamp(y, 0, 100 - size.height), + }; +}; + +/** The box after moving the pointer to `point` during `drag`. */ +export const resolveReferenceCropDrag = ( + drag: ReferenceCropDrag, + point: { x: number; y: number }, + constraints: ReferenceCropConstraints +): ReferenceImageCropBoxPct => { + if (drag.mode === 'move') { + const { box } = drag; + return { + ...box, + x: clamp(box.x + point.x - drag.x, 0, 100 - box.width), + y: clamp(box.y + point.y - drag.y, 0, 100 - box.height), + }; + } + return constraints.ratio === null + ? resolveFreeDrag(drag, point, constraints) + : resolveRatioDrag(drag, point, constraints, constraints.ratio); +}; diff --git a/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts new file mode 100644 index 00000000000..0908c709457 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { + constrainReferenceCropBox, + matchReferenceCropAspectRatio, + REFERENCE_CROP_ASPECT_RATIOS, + resolveReferenceCropDrag, + type ReferenceCropConstraints, + type ReferenceCropDragMode, +} from './referenceImage'; + +const IMAGE = { height: 600, width: 800 }; +const constraints = (ratio: number | null, image = IMAGE): ReferenceCropConstraints => ({ + image, + minDimensionPx: 64, + ratio, +}); +/** The pixel ratio a percent box has on an image. */ +const pixelRatio = (box: { height: number; width: number }, image = IMAGE): number => + (box.width * image.width) / (box.height * image.height); +const drag = ( + mode: ReferenceCropDragMode, + box: { height: number; width: number; x: number; y: number }, + from: { x: number; y: number }, + to: { x: number; y: number }, + ratio: number | null +) => resolveReferenceCropDrag({ box, mode, ...from }, to, constraints(ratio)); +const expectInside = (box: { height: number; width: number; x: number; y: number }) => { + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.y).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(100 + 1e-9); + expect(box.y + box.height).toBeLessThanOrEqual(100 + 1e-9); +}; + +describe('constrainReferenceCropBox', () => { + it('reshapes a box to the preset ratio around its center while keeping its area', () => { + const box = { height: 50, width: 25, x: 50, y: 25 }; + const square = constrainReferenceCropBox(box, constraints(1)); + + expect(pixelRatio(square)).toBeCloseTo(1); + expect(square.width * square.height).toBeCloseTo(box.width * box.height); + expect(square.x + square.width / 2).toBeCloseTo(62.5); + expect(square.y + square.height / 2).toBeCloseTo(50); + }); + + it('shrinks and slides a box that would otherwise leave the image, and leaves a free box alone', () => { + const box = { height: 100, width: 100, x: 0, y: 0 }; + const wide = constrainReferenceCropBox(box, constraints(16 / 9)); + + expect(pixelRatio(wide)).toBeCloseTo(16 / 9); + expect(wide.width).toBe(100); + expectInside(wide); + expect(constrainReferenceCropBox(box, constraints(null))).toBe(box); + }); + + it('never drops an edge below the minimum pixel size', () => { + const tiny = constrainReferenceCropBox({ height: 2, width: 2, x: 10, y: 10 }, constraints(16 / 9)); + + expect(tiny.height * IMAGE.height).toBeGreaterThanOrEqual(64 * 100 - 1e-6); + expect(pixelRatio(tiny)).toBeCloseTo(16 / 9); + }); +}); + +describe('resolveReferenceCropDrag', () => { + const box = { height: 40, width: 40, x: 30, y: 30 }; + + it('moves within the image and resizes freely from any handle', () => { + expect(drag('move', box, { x: 50, y: 50 }, { x: 90, y: 20 }, null)).toEqual({ ...box, x: 60, y: 0 }); + expect(drag('se', box, { x: 70, y: 70 }, { x: 80, y: 60 }, null)).toEqual({ height: 30, width: 50, x: 30, y: 30 }); + expect(drag('nw', box, { x: 30, y: 30 }, { x: 0, y: 0 }, null)).toEqual({ height: 70, width: 70, x: 0, y: 0 }); + }); + + it('keeps a corner drag on the preset ratio, anchored at the opposite corner', () => { + // Pixel-square on a 4:3 image: 50% of the width is 400px, so the height becomes 400px, two thirds of 600. + const grown = drag('se', box, { x: 70, y: 70 }, { x: 80, y: 75 }, 1); + + expect(pixelRatio(grown)).toBeCloseTo(1); + expect(grown.x).toBe(30); + expect(grown.y).toBe(30); + expect(grown.width).toBeCloseTo(50); + expect(grown.height).toBeCloseTo(66.667, 2); + expectInside(grown); + }); + + it('grows the other dimension around the center line for an edge drag', () => { + const wider = drag('e', box, { x: 70, y: 50 }, { x: 80, y: 50 }, 1); + + expect(pixelRatio(wider)).toBeCloseTo(1); + expect(wider.x).toBe(30); + expect(wider.width).toBeCloseTo(50); + expect(wider.y + wider.height / 2).toBeCloseTo(50); + }); + + it.each([ + ['e', { height: 40, width: 40, x: 0, y: 0 }, { x: 40, y: 20 }, { x: 100, y: 20 }], + ['w', { height: 40, width: 40, x: 60, y: 60 }, { x: 60, y: 80 }, { x: 0, y: 80 }], + ['s', { height: 40, width: 40, x: 0, y: 0 }, { x: 20, y: 40 }, { x: 20, y: 100 }], + ['n', { height: 40, width: 40, x: 60, y: 60 }, { x: 80, y: 60 }, { x: 80, y: 0 }], + ] as const)('lets the %s side grow to the full image by sliding the other axis', (mode, box, from, to) => { + const grown = resolveReferenceCropDrag({ box, mode, ...from }, to, constraints(1, { width: 800, height: 800 })); + expect(grown).toEqual({ x: 0, y: 0, width: 100, height: 100 }); + }); + + it.each(['nw', 'ne', 'sw', 'se'] as const)('shrinks the %s corner with either single-axis gesture', (mode) => { + const image = { width: 600, height: 800 }; + // A 240px square on a portrait source. + const box = { x: 30, y: 30, width: 40, height: 30 }; + const from = { x: mode.includes('w') ? 30 : 70, y: mode.includes('n') ? 30 : 60 }; + for (const to of [ + { ...from, x: from.x + (mode.includes('w') ? 10 : -10) }, + { ...from, y: from.y + (mode.includes('n') ? 7.5 : -7.5) }, + ]) { + const shrunk = resolveReferenceCropDrag({ box, mode, ...from }, to, constraints(1, image)); + expect(shrunk.width).toBeCloseTo(30); + expect(shrunk.height).toBeCloseTo(22.5); + expect(shrunk.x + (mode.includes('w') ? shrunk.width : 0)).toBeCloseTo(mode.includes('w') ? 70 : 30); + expect(shrunk.y + (mode.includes('n') ? shrunk.height : 0)).toBeCloseTo(mode.includes('n') ? 60 : 30); + expectInside(shrunk); + } + }); + + it('stops a ratio-locked drag at the image edge instead of breaking the ratio', () => { + const pinned = drag('se', box, { x: 70, y: 70 }, { x: 100, y: 100 }, 16 / 9); + + expect(pixelRatio(pinned)).toBeCloseTo(16 / 9); + expectInside(pinned); + expect(pinned.x + pinned.width).toBeCloseTo(100); + }); +}); + +describe('matchReferenceCropAspectRatio', () => { + it('recognizes every preset from a pixel-rounded box and reads anything else as free', () => { + for (const { id, ratio } of REFERENCE_CROP_ASPECT_RATIOS) { + if (ratio === null) { + continue; + } + const heightPx = 300; + const widthPx = Math.round(heightPx * ratio); + const box = { height: (heightPx / 600) * 100, width: (widthPx / 800) * 100, x: 0, y: 0 }; + expect(matchReferenceCropAspectRatio(box, IMAGE)).toBe(id); + } + expect(matchReferenceCropAspectRatio({ height: 50, width: 37, x: 0, y: 0 }, IMAGE)).toBe('free'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx index 64ed5b23819..534ee37694e 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx @@ -162,7 +162,7 @@ const createStableGroups = () => ({ CanvasRenderSize: () => null, account: { currentUserId: null, multiuserEnabled: false }, capabilities: { canManagePromptTemplates: false, canManageSharedSystemPrompts: false }, - gallery: { findImage: noop, selectedImage: null, touchImages: noop }, + gallery: { findImage: noop, selectedImage: null, touchImages: noop, uploadBoardId: 'none' }, models: { ModelSelect: MainModelPicker, catalog: CATALOG, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx index 621078e4b42..98970c383f0 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx @@ -100,6 +100,8 @@ export interface GenerationUiAdapter { findImage(imageName: string): void; selectedImage: GenerationSelectedImage | null; touchImages(): void; + /** The Gallery's auto-add board, where uploads made from Generate land; `none` is Uncategorized. */ + uploadBoardId: string; }; models: { ModelSelect: ComponentType; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx index 300e322eebd..215b0789944 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx @@ -1,14 +1,23 @@ import type { GenerateReferenceImageAsset } from '@features/generation/core/types'; -import type { CSSProperties, PointerEvent } from 'react'; +import type { LucideIcon } from 'lucide-react'; +import type { CSSProperties, PointerEvent, RefCallback } from 'react'; -import { Box, Portal, Stack, Text } from '@chakra-ui/react'; +import { Box, createListCollection, HStack, Portal, Spacer, Stack, Text } from '@chakra-ui/react'; import { galleryTransfers } from '@features/gallery'; import { + constrainReferenceCropBox, FULL_REFERENCE_IMAGE_CROP_BOX, generatedImageToReferenceImage, getReferenceImageCropBoxPct, isFullReferenceImageCropBox, + matchReferenceCropAspectRatio, + REFERENCE_CROP_ASPECT_RATIOS, + resolveReferenceCropDrag, resolveReferenceImageCrop, + type ReferenceCropAspectRatioId, + type ReferenceCropDrag, + type ReferenceCropDragMode, + type ReferenceCropHandle, type ReferenceImageCropBoxPct, } from '@features/generation/core/referenceImage'; import { getReferenceImageUrls } from '@features/generation/data/referenceImageUrls'; @@ -19,21 +28,26 @@ import { isAccountScopeCurrent, type AccountScope, } from '@platform/state/accountLifecycle'; -import { Button, CloseButton } from '@platform/ui'; +import { Button, CloseButton, IconButton } from '@platform/ui'; import { Dialog } from '@platform/ui/Dialog'; +import { Select } from '@platform/ui/Select'; +import { Tooltip } from '@platform/ui/Tooltip'; +import { MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -type HandleName = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; - -type DragMode = 'move' | HandleName; - -type DragState = { box: ReferenceImageCropBoxPct; mode: DragMode; x: number; y: number }; - /** Matches the legacy cropper's minimum crop dimension, in image pixels. */ const MIN_CROP_DIMENSION_PX = 64; -const HANDLES: { cursor: string; left: string; name: HandleName; top: string }[] = [ +/** Zoom is relative to the fitted image; wheel deltas are normalized to CSS pixels. */ +const ZOOM_WHEEL_PIXELS_PER_STEP = 100; +const ZOOM_WHEEL_FACTOR = 1.1; +const PAN_VISIBLE_PX = 48; +const ZOOM_BUTTON_FACTOR = 1.2; +const ZOOM_MIN = 0.25; +const ZOOM_MAX = 16; + +const HANDLES: { cursor: string; left: string; name: ReferenceCropHandle; top: string }[] = [ { cursor: 'nwse-resize', left: '0%', name: 'nw', top: '0%' }, { cursor: 'ns-resize', left: '50%', name: 'n', top: '0%' }, { cursor: 'nesw-resize', left: '100%', name: 'ne', top: '0%' }, @@ -46,63 +60,92 @@ const HANDLES: { cursor: string; left: string; name: HandleName; top: string }[] const THIRDS_OFFSETS = ['33.333%', '66.667%']; -const CONTAIN_IMG_STYLE: CSSProperties = { height: '100%', objectFit: 'contain', width: '100%' }; +const ASPECT_RATIO_TRIGGER_PROPS = { minW: '7rem' } as const; + +const CONTAIN_IMG_STYLE: CSSProperties = { display: 'block', height: '100%', objectFit: 'contain', width: '100%' }; -const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); +/** Fit: the image fills the viewport; pan is in viewport pixels. */ +interface CropView { + panX: number; + panY: number; + zoom: number; +} -const getPointerPct = (event: PointerEvent, element: HTMLElement): { x: number; y: number } => { - const rect = element.getBoundingClientRect(); +const FIT_VIEW: CropView = { panX: 0, panY: 0, zoom: 1 }; +/** Keep a reachable part of the image visible, including when zoomed smaller than the viewport. */ +const constrainViewPan = (view: CropView, viewport: { width: number; height: number }): CropView => { + const width = viewport.width * view.zoom; + const height = viewport.height * view.zoom; + const visibleX = Math.min(PAN_VISIBLE_PX, width, viewport.width); + const visibleY = Math.min(PAN_VISIBLE_PX, height, viewport.height); return { - x: ((event.clientX - rect.left) / rect.width) * 100, - y: ((event.clientY - rect.top) / rect.height) * 100, + ...view, + panX: Math.min(viewport.width - visibleX, Math.max(visibleX - width, view.panX)), + panY: Math.min(viewport.height - visibleY, Math.max(visibleY - height, view.panY)), }; }; -const resolveDrag = ( - drag: DragState, - point: { x: number; y: number }, - minWidth: number, - minHeight: number -): ReferenceImageCropBoxPct => { - const dx = point.x - drag.x; - const dy = point.y - drag.y; - const box = drag.box; - - if (drag.mode === 'move') { - return { - ...box, - x: clamp(box.x + dx, 0, 100 - box.width), - y: clamp(box.y + dy, 0, 100 - box.height), - }; - } +type PanState = 'idle' | 'ready' | 'panning'; - let left = box.x; - let top = box.y; - let right = box.x + box.width; - let bottom = box.y + box.height; +const isDragMode = (value: unknown): value is ReferenceCropDragMode => + value === 'move' || HANDLES.some((handle) => handle.name === value); - if (drag.mode.includes('w')) { - left = clamp(left + dx, 0, right - minWidth); - } +const isAspectRatioId = (value: unknown): value is ReferenceCropAspectRatioId => + REFERENCE_CROP_ASPECT_RATIOS.some((preset) => preset.id === value); - if (drag.mode.includes('e')) { - right = clamp(right + dx, left + minWidth, 100); - } +/** Space pans only when nothing that activates on Space has focus; buttons and fields keep their own behaviour. */ +const consumesSpace = (target: EventTarget | null): boolean => + target instanceof Element && + target.closest( + 'button, [role="button"], input, textarea, select, [contenteditable], [role="combobox"], [role="listbox"]' + ) !== null; - if (drag.mode.includes('n')) { - top = clamp(top + dy, 0, bottom - minHeight); - } +/** A saved partial crop reopens on its preset; the full image opens free, so the first drag is unconstrained. */ +const initialAspectRatioId = (image: GenerateReferenceImageAsset): ReferenceCropAspectRatioId => { + const box = getReferenceImageCropBoxPct(image); + return isFullReferenceImageCropBox(box) ? 'free' : matchReferenceCropAspectRatio(box, image.original.image); +}; - if (drag.mode.includes('s')) { - bottom = clamp(bottom + dy, top + minHeight, 100); - } +/** Pointer position as a percentage of the (transformed) stage, which holds the image at its on-screen size. */ +const getPointerPct = (event: PointerEvent, stage: HTMLElement): { x: number; y: number } => { + const rect = stage.getBoundingClientRect(); - return { height: bottom - top, width: right - left, x: left, y: top }; + return { + x: ((event.clientX - rect.left) / rect.width) * 100, + y: ((event.clientY - rect.top) / rect.height) * 100, + }; }; -const isDragMode = (value: unknown): value is DragMode => - value === 'move' || HANDLES.some((handle) => handle.name === value); +const CropViewButton = ({ icon: Icon, label, onClick }: { icon: LucideIcon; label: string; onClick: () => void }) => ( + + + + + +); + +/** Capture keeps a drag alive outside the viewport; without it (synthetic pointers) the viewport still sees moves. */ +const capturePointer = (element: HTMLElement, pointerId: number): void => { + try { + element.setPointerCapture(pointerId); + } catch { + // No capture available for this pointer. + } +}; + +/** Zooms by `factor` about a viewport point, so the pixel under the cursor stays put. */ +const zoomViewAt = (view: CropView, factor: number, point: { x: number; y: number }): CropView => { + // Actual size can sit beyond the normal range; controls may move back toward it without reversing direction. + const zoom = Math.min(Math.max(ZOOM_MAX, view.zoom), Math.max(Math.min(ZOOM_MIN, view.zoom), view.zoom * factor)); + const scale = zoom / view.zoom; + + return { + panX: point.x - (point.x - view.panX) * scale, + panY: point.y - (point.y - view.panY) * scale, + zoom, + }; +}; export const exportCroppedReferenceImage = async ( image: GenerateReferenceImageAsset, @@ -182,17 +225,42 @@ export const ReferenceImageCropDialog = ({ }) => { const { t } = useTranslation(); const { gallery, notifications } = useGenerationUi(); + const original = image.original.image; const [cropBox, setCropBox] = useState(() => getReferenceImageCropBoxPct(image)); + const [aspectRatioId, setAspectRatioId] = useState(() => initialAspectRatioId(image)); + const [view, setView] = useState(FIT_VIEW); + const [panState, setPanState] = useState('idle'); const [isApplying, setIsApplying] = useState(false); - const containerRef = useRef(null); - const dragRef = useRef(null); - const original = image.original.image; + const [isSaving, setIsSaving] = useState(false); + const viewportRef = useRef(null); + const stageRef = useRef(null); + const dragRef = useRef(null); + const panRef = useRef<{ x: number; y: number } | null>(null); + const spaceHeldRef = useRef(false); const imageUrl = getReferenceImageUrls({ original: image.original }).imageUrl; const ratio = original.height > 0 ? original.width / original.height : 1; - const minWidthPct = Math.min(100, (MIN_CROP_DIMENSION_PX / Math.max(1, original.width)) * 100); - const minHeightPct = Math.min(100, (MIN_CROP_DIMENSION_PX / Math.max(1, original.height)) * 100); + const constraints = useMemo( + () => ({ + image: original, + minDimensionPx: MIN_CROP_DIMENSION_PX, + ratio: REFERENCE_CROP_ASPECT_RATIOS.find((preset) => preset.id === aspectRatioId)?.ratio ?? null, + }), + [aspectRatioId, original] + ); const cropWidthPx = Math.max(1, Math.round((cropBox.width / 100) * original.width)); const cropHeightPx = Math.max(1, Math.round((cropBox.height / 100) * original.height)); + const isBusy = isApplying || isSaving; + + const aspectRatioCollection = useMemo( + () => + createListCollection({ + items: REFERENCE_CROP_ASPECT_RATIOS.map(({ id }) => ({ + label: id === 'free' ? t('widgets.generate.cropAspectRatioFree') : id, + value: id, + })), + }), + [t] + ); const cropBoxStyle = useMemo( () => ({ @@ -203,13 +271,17 @@ export const ReferenceImageCropDialog = ({ }), [cropBox] ); + const stageStyle = useMemo( + () => ({ transform: `translate(${view.panX}px, ${view.panY}px) scale(${view.zoom})`, transformOrigin: '0 0' }), + [view] + ); const close = useCallback(() => { - if (!isApplying) { + if (!isBusy) { setCropBox(getReferenceImageCropBoxPct(image)); onClose(); } - }, [image, isApplying, onClose]); + }, [image, isBusy, onClose]); const handleOpenChange = useCallback( (event: Dialog.OpenChangeDetails) => { @@ -220,46 +292,169 @@ export const ReferenceImageCropDialog = ({ [close] ); + // Wheel zoom must cancel the dialog's own scrolling, which React's passive wheel listener cannot; Space is read + // window-wide so panning works wherever focus sits, except in fields that use Space themselves. + const viewportCallbackRef = useCallback>((element) => { + viewportRef.current = element; + if (!element) { + return; + } + const onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) { + return; + } + event.preventDefault(); + const rect = element.getBoundingClientRect(); + const deltaPx = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rect.height : 1); + const factor = ZOOM_WHEEL_FACTOR ** (-deltaPx / ZOOM_WHEEL_PIXELS_PER_STEP); + setView((current) => + constrainViewPan( + zoomViewAt(current, factor, { x: event.clientX - rect.left, y: event.clientY - rect.top }), + rect + ) + ); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Space' && !consumesSpace(event.target)) { + event.preventDefault(); + if (!event.repeat) { + spaceHeldRef.current = true; + setPanState((current) => (current === 'idle' ? 'ready' : current)); + } + } + }; + const releaseSpace = (event: KeyboardEvent | FocusEvent) => { + if (!('code' in event) || event.code === 'Space') { + spaceHeldRef.current = false; + setPanState((current) => (current === 'ready' ? 'idle' : current)); + } + }; + element.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', releaseSpace); + window.addEventListener('blur', releaseSpace); + return () => { + element.removeEventListener('wheel', onWheel); + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', releaseSpace); + window.removeEventListener('blur', releaseSpace); + }; + }, []); + + const zoomAboutCenter = useCallback((factor: number) => { + const viewport = viewportRef.current; + if (viewport) { + const rect = viewport.getBoundingClientRect(); + setView((current) => + constrainViewPan(zoomViewAt(current, factor, { x: rect.width / 2, y: rect.height / 2 }), rect) + ); + } + }, []); + const zoomIn = useCallback(() => zoomAboutCenter(ZOOM_BUTTON_FACTOR), [zoomAboutCenter]); + const zoomOut = useCallback(() => zoomAboutCenter(1 / ZOOM_BUTTON_FACTOR), [zoomAboutCenter]); + const fitView = useCallback(() => setView(FIT_VIEW), []); + // Actual size: one image pixel per CSS pixel, centered. + const resetView = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport || viewport.clientWidth === 0) { + return; + } + const rect = viewport.getBoundingClientRect(); + const zoom = original.width / rect.width; + setView({ + panX: (rect.width * (1 - zoom)) / 2, + panY: (rect.height * (1 - zoom)) / 2, + zoom, + }); + }, [original.width]); + + const handleAspectRatioChange = useCallback( + ({ value }: { value: string[] }) => { + const next = value[0]; + if (!isAspectRatioId(next)) { + return; + } + setAspectRatioId(next); + setCropBox((current) => + constrainReferenceCropBox(current, { + ...constraints, + ratio: REFERENCE_CROP_ASPECT_RATIOS.find((preset) => preset.id === next)?.ratio ?? null, + }) + ); + }, + [constraints] + ); + + const beginPan = useCallback( + (event: PointerEvent) => { + const viewport = viewportRef.current; + if (!viewport || dragRef.current || (event.button !== 1 && !(event.button === 0 && panState === 'ready'))) { + return; + } + event.preventDefault(); + capturePointer(viewport, event.pointerId); + panRef.current = { x: event.clientX, y: event.clientY }; + setPanState('panning'); + }, + [panState] + ); + const beginDrag = useCallback( (event: PointerEvent) => { - const container = containerRef.current; + const viewport = viewportRef.current; + const stage = stageRef.current; const mode = (event.currentTarget as HTMLElement).dataset.dragMode; - if (!container || !isDragMode(mode)) { + // Space or the middle button pans from anywhere, the crop box included; let it reach the viewport. + if (!viewport || !stage || !isDragMode(mode) || panState === 'ready' || event.button !== 0) { return; } event.preventDefault(); event.stopPropagation(); - container.setPointerCapture(event.pointerId); - dragRef.current = { box: cropBox, mode, ...getPointerPct(event, container) }; + capturePointer(viewport, event.pointerId); + dragRef.current = { box: cropBox, mode, ...getPointerPct(event, stage) }; }, - [cropBox] + [cropBox, panState] ); - const moveDrag = useCallback( + const movePointer = useCallback( (event: PointerEvent) => { - const container = containerRef.current; - const drag = dragRef.current; - - if (!container || !drag) { + const pan = panRef.current; + const viewport = viewportRef.current; + if (pan && viewport) { + const dx = event.clientX - pan.x; + const dy = event.clientY - pan.y; + panRef.current = { x: event.clientX, y: event.clientY }; + const rect = viewport.getBoundingClientRect(); + setView((current) => constrainViewPan({ ...current, panX: current.panX + dx, panY: current.panY + dy }, rect)); return; } - - setCropBox(resolveDrag(drag, getPointerPct(event, container), minWidthPct, minHeightPct)); + const stage = stageRef.current; + const drag = dragRef.current; + if (stage && drag) { + setCropBox(resolveReferenceCropDrag(drag, getPointerPct(event, stage), constraints)); + } }, - [minHeightPct, minWidthPct] + [constraints] ); - const endDrag = useCallback((event: PointerEvent) => { + const endPointer = useCallback((event: PointerEvent) => { dragRef.current = null; - - if (containerRef.current?.hasPointerCapture(event.pointerId)) { - containerRef.current.releasePointerCapture(event.pointerId); + if (panRef.current) { + panRef.current = null; + // Space still held keeps the viewport ready for the next pan. + setPanState(spaceHeldRef.current ? 'ready' : 'idle'); + } + if (viewportRef.current?.hasPointerCapture(event.pointerId)) { + viewportRef.current.releasePointerCapture(event.pointerId); } }, []); - const resetCrop = useCallback(() => setCropBox(FULL_REFERENCE_IMAGE_CROP_BOX), []); + const resetCrop = useCallback(() => { + setAspectRatioId('free'); + setCropBox(FULL_REFERENCE_IMAGE_CROP_BOX); + }, []); const applyCrop = useCallback(async () => { const owner = captureAccountScope(); @@ -301,6 +496,38 @@ export const ReferenceImageCropDialog = ({ } }, [close, cropBox, gallery, image, notifications, onApply, onClose]); + // A durable copy of the crop in Uploads on the Gallery's auto-add board, unrelated to applying it: the dialog + // stays open and the reference unchanged. + const saveToAssets = useCallback(async () => { + const owner = captureAccountScope(); + setIsSaving(true); + + try { + const file = await exportCroppedReferenceImage(image, cropBox, owner); + assertAccountScopeCurrent(owner); + await galleryTransfers.upload(file, gallery.uploadBoardId, { isIntermediate: false, signal: owner.signal }); + assertAccountScopeCurrent(owner); + gallery.touchImages(); + notifications.info(t('widgets.generate.cropSavedToAssets')); + } catch (error) { + if (!isAccountScopeCurrent(owner)) { + return; + } + + notifications.reportError({ + area: 'reference-images', + message: error instanceof Error ? error.message : String(error), + namespace: 'generation', + }); + } finally { + if (isAccountScopeCurrent(owner)) { + setIsSaving(false); + } + } + }, [cropBox, gallery, image, notifications, t]); + + const aspectRatioValue = useMemo(() => [aspectRatioId], [aspectRatioId]); + return ( {t('widgets.generate.cropReferenceImage')} - + - - {t('widgets.generate.cropReferenceImageHelp')} - + + + {t('widgets.generate.aspectRatio')} + +