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 aa05b74a55b..07f39fcafd0 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 @@ -94,6 +94,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..71bc2a3e446 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. * **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 22f18556c02..4ff8010c5bb 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 lasts for the session. * **Delete layer**. * **Raster layers:** * **Transform**. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..8a2660278be 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2313,8 +2313,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", @@ -2957,6 +2964,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", @@ -3390,6 +3398,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", @@ -3448,6 +3457,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", @@ -4098,6 +4108,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..c01e9b34ea8 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,28 @@ describe('GalleryImageGrid mixed item cells', () => { expect(imageActionMocks.openItemInPreview).not.toHaveBeenCalled(); }); + it('opens a middle-clicked tile in a new tab only when the Gallery setting opts in', async () => { + const auxclick = (button: HTMLButtonElement) => + interact(() => { + button.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 1, cancelable: true })); + button.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })); + }); + + await renderGallery(); + await auxclick(getButton('Select last.png for preview')); + expect(imageActionMocks.openItemInNewTab).not.toHaveBeenCalled(); + + const gallery = createGallery(); + await renderGallery({ ...gallery, settings: { ...gallery.settings, middleClickOpensInNewTab: true } }); + await auxclick(getButton('Select last.png for preview')); + expect(imageActionMocks.openItemInNewTab).toHaveBeenCalledExactlyOnceWith(createItem('image', 'last.png')); + await auxclick(getButton('Select video shared, duration 1:06, for preview')); + expect(imageActionMocks.openItemInNewTab).toHaveBeenLastCalledWith(createItem('video', 'shared')); + // The middle button never selects or opens in Preview. + expect(actionMocks.selectItem).not.toHaveBeenCalled(); + expect(imageActionMocks.openItemInPreview).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..e8681152f12 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts @@ -183,3 +183,209 @@ 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 whichever free dimension grew most; + * edges anchor the opposite edge and grow the other dimension around the box's center line. + */ +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.max(free.width, free.height * pctRatio); + } + let size = fitRatioBox(width, pctRatio, constraints); + // Edges grow the other dimension around a center line, which caps it at twice the nearer image edge. + const maxWidth = horizontal + ? Math.min(roomWidth, 2 * Math.min(cy, 100 - cy) * pctRatio) + : vertical + ? Math.min(roomHeight * pctRatio, 2 * Math.min(cx, 100 - cx)) + : 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..3969846e086 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts @@ -0,0 +1,116 @@ +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('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 be4d28dd861..8cee9a7aa05 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 @@ -135,7 +135,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..92da61db5f2 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,24 @@ 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, ScanIcon, 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; the legacy cropper's wheel and button steps apply. */ +const ZOOM_WHEEL_FACTOR = 1.1; +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,12 +58,43 @@ const HANDLES: { cursor: string; left: string; name: HandleName; top: string }[] const THIRDS_OFFSETS = ['33.333%', '66.667%']; +const ASPECT_RATIO_TRIGGER_PROPS = { minW: '7rem' } as const; + const CONTAIN_IMG_STYLE: CSSProperties = { 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 FIT_VIEW: CropView = { panX: 0, panY: 0, zoom: 1 }; -const getPointerPct = (event: PointerEvent, element: HTMLElement): { x: number; y: number } => { - const rect = element.getBoundingClientRect(); +type PanState = 'idle' | 'ready' | 'panning'; + +const isDragMode = (value: unknown): value is ReferenceCropDragMode => + value === 'move' || HANDLES.some((handle) => handle.name === value); + +const isAspectRatioId = (value: unknown): value is ReferenceCropAspectRatioId => + REFERENCE_CROP_ASPECT_RATIOS.some((preset) => preset.id === value); + +/** 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; + +/** 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); +}; + +/** 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 { x: ((event.clientX - rect.left) / rect.width) * 100, @@ -59,51 +102,35 @@ const getPointerPct = (event: PointerEvent, element: HTMLElement): { x: number; }; }; -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), - }; - } - - let left = box.x; - let top = box.y; - let right = box.x + box.width; - let bottom = box.y + box.height; - - if (drag.mode.includes('w')) { - left = clamp(left + dx, 0, right - minWidth); - } - - if (drag.mode.includes('e')) { - right = clamp(right + dx, left + minWidth, 100); - } - - if (drag.mode.includes('n')) { - top = clamp(top + dy, 0, bottom - minHeight); +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. } +}; - if (drag.mode.includes('s')) { - bottom = clamp(bottom + dy, top + minHeight, 100); - } +/** 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 => { + const zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, view.zoom * factor)); + const scale = zoom / view.zoom; - return { height: bottom - top, width: right - left, x: left, y: top }; + return { + panX: point.x - (point.x - view.panX) * scale, + panY: point.y - (point.y - view.panY) * scale, + zoom, + }; }; -const isDragMode = (value: unknown): value is DragMode => - value === 'move' || HANDLES.some((handle) => handle.name === value); - export const exportCroppedReferenceImage = async ( image: GenerateReferenceImageAsset, cropBox: ReferenceImageCropBoxPct, @@ -182,17 +209,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<{ panX: number; panY: number; 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 +255,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 +276,155 @@ 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) => { + event.preventDefault(); + const rect = element.getBoundingClientRect(); + const factor = event.deltaY < 0 ? ZOOM_WHEEL_FACTOR : 1 / ZOOM_WHEEL_FACTOR; + setView((current) => zoomViewAt(current, factor, { x: event.clientX - rect.left, y: event.clientY - rect.top })); + }; + 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) { + setView((current) => zoomViewAt(current, factor, { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 })); + } + }, []); + 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 zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, original.width / viewport.clientWidth)); + setView({ + panX: (viewport.clientWidth * (1 - zoom)) / 2, + panY: (viewport.clientHeight * (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 || (panState !== 'ready' && event.button !== 1)) { + return; + } + event.preventDefault(); + capturePointer(viewport, event.pointerId); + panRef.current = { panX: view.panX, panY: view.panY, x: event.clientX, y: event.clientY }; + setPanState('panning'); + }, + [panState, view.panX, view.panY] + ); + 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; + if (pan) { + setView((current) => ({ + ...current, + panX: pan.panX + event.clientX - pan.x, + panY: pan.panY + event.clientY - pan.y, + })); 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 +466,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')} + +