From 1559baaf602a382d98297d9e149ac8cb8c529b5e Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 21:47:21 -0600 Subject: [PATCH 1/4] feat(canvas): restore eraser pressure width, layer quick switch and middle-click open Eraser honours pen pressure for width through its own Dynamics switch; Alt+[ / Alt+] cycle layers when nothing is staged and Q jumps to a bookmarked or previous layer via engine-held memory and a layer-menu bookmark. Reference crop dialog gains aspect-ratio presets, wheel zoom, Space/middle-button pan, view buttons and Save to Uploads; Gallery gets an opt-in middle-click-opens-in-new-tab setting on image and video tiles. --- .../04.Image Generation/generate-panel.mdx | 2 + .../06.Gallery/gallery-reference.mdx | 2 +- .../Users Guide/07.Canvas/02.canvas-guide.mdx | 5 +- .../Users Guide/07.Canvas/03.layers-panel.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 13 +- .../src/features/gallery/core/settings.ts | 4 + .../features/gallery/settingsContribution.ts | 8 + .../ui/GalleryImageGrid.browser.test.tsx | 22 + .../features/gallery/ui/GalleryImageGrid.tsx | 5 + .../ui/GallerySettingField.browser.test.tsx | 13 + .../gallery/ui/GallerySettingField.tsx | 1 + .../features/gallery/ui/GalleryThumbnail.tsx | 26 + .../gallery/ui/galleryStateView.test.ts | 1 + .../core/referenceImageCropGeometry.test.ts | 116 ++++ .../core/referenceImageCropGeometry.ts | 208 +++++++ .../ReferenceImageCropDialog.tsx | 522 +++++++++++++----- .../referenceImageCrop.browser.test.tsx | 106 +++- .../workbench/canvas-engine/capabilities.ts | 5 + .../workbench/canvas-engine/engine.test.ts | 31 ++ .../src/workbench/canvas-engine/engine.ts | 10 + .../workbench/canvas-engine/engineStores.ts | 30 +- .../canvas-engine/tools/eraserTool.ts | 7 +- .../canvas-engine/tools/paintTool.test.ts | 23 + .../webv2/src/workbench/hotkeys/catalog.ts | 1 + .../GalleryMaintenance.browser.test.tsx | 2 + .../widgets/canvas/CanvasWidgetView.tsx | 1 + .../canvas/canvasHotkeyCommands.test.ts | 58 +- .../widgets/canvas/canvasHotkeyCommands.ts | 39 ++ .../widgets/canvas/engineStoreHooks.ts | 16 + .../widgets/canvas/tool-options/paintForm.tsx | 48 +- .../widgets/layers/LayerContextMenu.tsx | 21 +- .../layers/layerContextActions.test.ts | 28 + .../widgets/layers/layerContextActions.ts | 27 + .../layers/layerContextMenuLayout.test.ts | 2 + .../panes/LayerEditorPanes.browser.test.tsx | 10 + 35 files changed, 1230 insertions(+), 185 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.ts 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/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/referenceImageCropGeometry.test.ts b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts new file mode 100644 index 00000000000..df93a12924a --- /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 './referenceImageCropGeometry'; + +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/core/referenceImageCropGeometry.ts b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.ts new file mode 100644 index 00000000000..df85a96a426 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.ts @@ -0,0 +1,208 @@ +/** + * Pure 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. + */ + +import type { ReferenceImageCropBoxPct } from './referenceImage'; + +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/ui/reference-images/ReferenceImageCropDialog.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageCropDialog.tsx index 300e322eebd..15869d6e649 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,7 +1,8 @@ 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 { FULL_REFERENCE_IMAGE_CROP_BOX, @@ -11,6 +12,16 @@ import { resolveReferenceImageCrop, type ReferenceImageCropBoxPct, } from '@features/generation/core/referenceImage'; +import { + constrainReferenceCropBox, + matchReferenceCropAspectRatio, + REFERENCE_CROP_ASPECT_RATIOS, + resolveReferenceCropDrag, + type ReferenceCropAspectRatioId, + type ReferenceCropDrag, + type ReferenceCropDragMode, + type ReferenceCropHandle, +} from '@features/generation/core/referenceImageCropGeometry'; import { getReferenceImageUrls } from '@features/generation/data/referenceImageUrls'; import { useGenerationUi } from '@features/generation/ui/GenerationUiContext'; import { @@ -19,21 +30,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 +60,35 @@ 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 }; + +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 consumes Space has focus; those keep their own behaviour. */ +const consumesSpace = (target: EventTarget | null): boolean => + target instanceof Element && + target.closest('input, textarea, select, [contenteditable], [role="combobox"], [role="listbox"]') !== null; -const getPointerPct = (event: PointerEvent, element: HTMLElement): { x: number; y: number } => { - const rect = element.getBoundingClientRect(); +/** 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 +96,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); +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('n')) { - top = clamp(top + dy, 0, bottom - minHeight); - } +/** 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; - if (drag.mode.includes('s')) { - bottom = clamp(bottom + dy, top + minHeight, 100); - } - - 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 +203,44 @@ 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(() => + matchReferenceCropAspectRatio(getReferenceImageCropBoxPct(image), original) + ); + 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 +251,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 +272,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 +462,38 @@ export const ReferenceImageCropDialog = ({ } }, [close, cropBox, gallery, image, notifications, onApply, onClose]); + // A durable copy of the crop in Uploads, 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, 'none', { 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')} + + { - host = document.createElement('div'); - document.body.append(host); - root = createRoot(host); - await act(() => +/** A reference whose saved crop is the centred 256px square of the 512px original. */ +const SQUARE_CROPPED_REFERENCE: GenerateReferenceImage = { + ...REFERENCE, + config: { + image: { + crop: { + box: { height: 256, width: 256, x: 128, y: 128 }, + image: { height: 256, image_name: 'crop.png', width: 256 }, + ratio: null, + }, + original: { image: { height: 512, image_name: 'original.png', width: 512 } }, + }, + type: 'external_reference_image', + }, +}; + +const renderCard = (referenceImage: GenerateReferenceImage) => + act(() => root?.render( @@ -67,7 +80,7 @@ beforeEach(async () => { { ) ); + +beforeEach(async () => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await renderCard(REFERENCE); }); afterEach(async () => { @@ -105,11 +124,31 @@ const readCropBox = (dialog: HTMLElement) => { }; }; -const pointer = (type: string, target: Element, clientX: number, clientY: number) => +const pointer = (type: string, target: Element, clientX: number, clientY: number, button = 0) => target.dispatchEvent( - new PointerEvent(type, { bubbles: true, button: 0, buttons: 1, clientX, clientY, isPrimary: true, pointerId: 7 }) + new PointerEvent(type, { + bubbles: true, + button, + buttons: button === 1 ? 4 : 1, + clientX, + clientY, + isPrimary: true, + pointerId: 7, + }) ); +/** `translate(x px, y px) scale(z)` from the stage's inline transform. */ +const readView = (dialog: HTMLElement) => { + const transform = dialog.querySelector('[data-crop-stage]')!.style.transform; + const [, x, y, zoom] = /translate\(([-\d.e]+)px, ([-\d.e]+)px\) scale\(([-\d.e]+)\)/.exec(transform) ?? []; + return { x: parseFloat(x ?? ''), y: parseFloat(y ?? ''), zoom: parseFloat(zoom ?? '') }; +}; + +const viewportOf = (dialog: HTMLElement) => dialog.querySelector('[data-crop-stage]')!.parentElement!; + +const spaceKey = (type: 'keydown' | 'keyup', target: EventTarget = window) => + target.dispatchEvent(new KeyboardEvent(type, { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); + describe('reference image crop dialog', () => { it('animates out instead of unmounting on close', async () => { await act(() => userEvent.click(document.querySelector('button[aria-label="common.crop"]')!)); @@ -161,6 +200,103 @@ describe('reference image crop dialog', () => { ); }); + it('opens the full image free, and a saved crop on the preset it matches', async () => { + let dialog = await openCropDialog(); + expect(dialog.querySelector('[role="combobox"]')?.textContent).toContain('widgets.generate.cropAspectRatioFree'); + await act(() => userEvent.keyboard('{Escape}')); + await expect.poll(() => document.querySelector('[role="dialog"]')).toBeNull(); + + await renderCard(SQUARE_CROPPED_REFERENCE); + dialog = await openCropDialog(); + expect(dialog.querySelector('[role="combobox"]')?.textContent).toContain('1:1'); + expect(readCropBox(dialog)).toEqual({ height: 50, width: 50, x: 25, y: 25 }); + }); + + it('zooms about the wheel cursor without scrolling the dialog, and pans with Space or the middle button', async () => { + const dialog = await openCropDialog(); + const viewport = viewportOf(dialog); + const rect = viewport.getBoundingClientRect(); + expect(readView(dialog)).toEqual({ x: 0, y: 0, zoom: 1 }); + + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + 100, + clientY: rect.top + 50, + deltaY: -100, + }); + await act(() => { + expect(viewport.dispatchEvent(wheel)).toBe(false); + }); + // The pixel under the cursor stays put: the stage grows by 1.1 about the cursor's viewport offset (the event + // stores integer client coordinates, so read the offset back from it). + const zoomed = readView(dialog); + expect(zoomed.zoom).toBeCloseTo(1.1, 3); + expect(zoomed.x).toBeCloseTo(-0.1 * (wheel.clientX - rect.left), 3); + expect(zoomed.y).toBeCloseTo(-0.1 * (wheel.clientY - rect.top), 3); + + // Space arms a pan from anywhere, and a drag on the crop box moves the view rather than the box. + await act(() => { + spaceKey('keydown'); + }); + expect(viewport.dataset.panState).toBe('ready'); + const box = dialog.querySelector('[data-crop-box]')!; + const before = readCropBox(dialog); + await act(() => { + pointer('pointerdown', box, rect.left + 200, rect.top + 200); + pointer('pointermove', viewport, rect.left + 230, rect.top + 220); + pointer('pointerup', viewport, rect.left + 230, rect.top + 220); + }); + const panned = readView(dialog); + expect(panned.zoom).toBe(zoomed.zoom); + expect(panned.x).toBeCloseTo(zoomed.x + 30, 0); + expect(panned.y).toBeCloseTo(zoomed.y + 20, 0); + expect(readCropBox(dialog)).toEqual(before); + // Still held, so the next drag pans too; releasing returns to crop editing. + expect(viewport.dataset.panState).toBe('ready'); + await act(() => { + spaceKey('keyup'); + }); + expect(viewport.dataset.panState).toBe('idle'); + + await act(() => { + pointer('pointerdown', viewport, rect.left + 50, rect.top + 50, 1); + pointer('pointermove', viewport, rect.left + 40, rect.top + 60, 1); + pointer('pointerup', viewport, rect.left + 40, rect.top + 60, 1); + }); + const middlePanned = readView(dialog); + expect(middlePanned.x).toBeCloseTo(panned.x - 10, 0); + expect(middlePanned.y).toBeCloseTo(panned.y + 10, 0); + expect(viewport.dataset.panState).toBe('idle'); + + await act(() => + userEvent.click( + [...dialog.querySelectorAll('button')].find( + (b) => b.getAttribute('aria-label') === 'widgets.generate.cropFitView' + )! + ) + ); + expect(readView(dialog)).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + it('leaves Space to a focused button, so Apply still activates from the keyboard', async () => { + const dialog = await openCropDialog(); + const apply = [...dialog.querySelectorAll('button')].find((button) => button.textContent === 'common.apply')!; + await act(() => apply.focus()); + + await act(() => { + expect(spaceKey('keydown', apply)).toBe(true); + }); + expect(viewportOf(dialog).dataset.panState).toBe('idle'); + await act(() => userEvent.keyboard(' ')); + + // The unchanged full crop applies and closes the dialog. + await expect.poll(() => noop.mock.calls.length).toBeGreaterThan(0); + await expect + .poll(() => document.querySelector('[role="dialog"]')?.getAttribute('data-state') ?? null) + .not.toBe('open'); + }); + it('saves the current crop to Uploads as a durable user image without applying it', async () => { mocks.upload.mockResolvedValue({ imageName: 'saved.png' }); const dialog = await openCropDialog(); @@ -175,7 +311,7 @@ describe('reference image crop dialog', () => { const [file, boardId, options] = mocks.upload.mock.calls[0] as [File, string, { isIntermediate: boolean }]; expect(file).toBeInstanceOf(File); expect(file.name).toBe('original-crop.png'); - expect(boardId).toBe('none'); + expect(boardId).toBe('board-7'); expect(options.isIntermediate).toBe(false); await expect.poll(() => (ADAPTER.notifications.info as ReturnType).mock.calls.length).toBe(1); expect(ADAPTER.gallery.touchImages).toHaveBeenCalledOnce(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts index d715f645250..777d98baf11 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts @@ -184,6 +184,30 @@ describe('layer navigation without staging', () => { expect(dispatch).not.toHaveBeenCalled(); }); + it('enters a selected group at its first leaf and leaves it upward, skipping an empty group to the ends', () => { + const grouped = { + ...documentOf([], 'g'), + stacks: stacksFrom([ + rasterLayer('a'), + groupContract('g', [rasterLayer('b'), rasterLayer('c')]), + rasterLayer('d'), + ]), + }; + run('canvas.nextEntity', { document: grouped, selectedLayerIds: ['g'] }); + expect(dispatch).toHaveBeenLastCalledWith(select('b')); + run('canvas.prevEntity', { document: grouped, selectedLayerIds: ['g'] }); + expect(dispatch).toHaveBeenLastCalledWith(select('a')); + + const empty = { + ...documentOf([], 'g'), + stacks: stacksFrom([rasterLayer('a'), groupContract('g'), rasterLayer('d')]), + }; + run('canvas.nextEntity', { document: empty, selectedLayerIds: ['g'] }); + expect(dispatch).toHaveBeenLastCalledWith(select('a')); + run('canvas.prevEntity', { document: empty, selectedLayerIds: ['g'] }); + expect(dispatch).toHaveBeenLastCalledWith(select('d')); + }); + it('stays put on the only layer and while interaction is locked', () => { run('canvas.nextEntity', { document: documentOf([rasterLayer('a')], 'a') }); run('canvas.prevEntity', { document: documentOf(layers, 'a'), isInteractionLocked: true }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts index 71f38346163..835f17437fb 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts @@ -47,13 +47,24 @@ const REORDER_KINDS: Record = { 'canvas.layerToFront': 'front', }; -/** The layer `steps` away from the selection in layer order, wrapping; none selected starts from either end. */ +/** + * The layer `steps` away from the selection in layer order, wrapping. A selected group stands at its first + * descendant leaf: next enters the group there, previous leaves it upward. None selected starts from either end. + */ const cycleLayerId = (document: CanvasDocumentContractV3, steps: 1 | -1): string | null => { - const { leaves } = getDocumentIndex(document); + const { byId, leaves } = getDocumentIndex(document); if (leaves.length === 0) { return null; } - const current = leaves.findIndex((leaf) => leaf.id === document.selectedLayerId); + const { selectedLayerId } = document; + const selected = selectedLayerId ? byId.get(selectedLayerId) : undefined; + if (selected && isGroupNode(selected.node)) { + const first = leaves.findIndex((leaf) => byId.get(leaf.id)?.path.includes(selected.node.id)); + if (first >= 0) { + return leaves[steps === 1 ? first : (first - 1 + leaves.length) % leaves.length]!.id; + } + } + const current = leaves.findIndex((leaf) => leaf.id === selectedLayerId); if (current < 0) { return leaves[steps === 1 ? 0 : leaves.length - 1]!.id; } From a55d61cf4936342b791c486c64edffbeda447981 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Fri, 16 Oct 2026 16:03:38 -0600 Subject: [PATCH 3/4] fix(canvas): correct quick switching and crop interactions Preserve quick-switch return targets and reveal navigated layers. Correct crop geometry, zoom and pan behavior with regression coverage. --- .../Users Guide/07.Canvas/02.canvas-guide.mdx | 2 +- .../Users Guide/07.Canvas/03.layers-panel.mdx | 2 +- .../ui/GalleryImageGrid.browser.test.tsx | 57 ++++-- .../generation/core/referenceImage.ts | 15 +- .../core/referenceImageCropGeometry.test.ts | 28 +++ .../ReferenceImageCropDialog.tsx | 72 +++++-- .../referenceImageCrop.browser.test.tsx | 181 ++++++++++++++++-- .../workbench/canvas-engine/ARCHITECTURE.md | 2 +- .../workbench/canvas-engine/engine.test.ts | 150 ++++++++++++++- .../src/workbench/canvas-engine/engine.ts | 39 +++- .../workbench/canvas-engine/engineStores.ts | 2 +- .../src/workbench/layerPanelState.test.ts | 14 +- .../webv2/src/workbench/layerPanelState.ts | 12 ++ .../layers/LayerSurfaceHost.browser.test.tsx | 23 ++- .../layers/LayersTree.browser.test.tsx | 51 ++++- 15 files changed, 572 insertions(+), 78 deletions(-) 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 71bc2a3e446..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**; and, under **Dynamics**, whether pen pressure affects the width. Pressure never changes how strongly the eraser erases. +* **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. 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 4ff8010c5bb..aa8ac8fd6f3 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**, **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. + * **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/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryImageGrid.browser.test.tsx index c01e9b34ea8..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,26 +1227,43 @@ 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(); + 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 () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts b/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts index e8681152f12..1b31983447f 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImage.ts @@ -319,8 +319,8 @@ const resolveFreeDrag = ( }; /** - * 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. + * 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, @@ -348,14 +348,17 @@ const resolveRatioDrag = ( } else if (horizontal) { width = free.width; } else { - width = Math.max(free.width, free.height * pctRatio); + 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); - // Edges grow the other dimension around a center line, which caps it at twice the nearer image edge. + // Side handles may use the full orthogonal image dimension; placement below slides their center if needed. const maxWidth = horizontal - ? Math.min(roomWidth, 2 * Math.min(cy, 100 - cy) * pctRatio) + ? Math.min(roomWidth, 100 * pctRatio) : vertical - ? Math.min(roomHeight * pctRatio, 2 * Math.min(cx, 100 - cx)) + ? Math.min(roomHeight * pctRatio, 100) : Math.min(roomWidth, roomHeight * pctRatio); if (size.width > maxWidth) { size = fitRatioBox(maxWidth, pctRatio, constraints); diff --git a/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts index 3969846e086..0908c709457 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/referenceImageCropGeometry.test.ts @@ -91,6 +91,34 @@ describe('resolveReferenceCropDrag', () => { 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); 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 92da61db5f2..6e17d59f811 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 @@ -39,8 +39,10 @@ import { useTranslation } from 'react-i18next'; /** Matches the legacy cropper's minimum crop dimension, in image pixels. */ const MIN_CROP_DIMENSION_PX = 64; -/** Zoom is relative to the fitted image; the legacy cropper's wheel and button steps apply. */ +/** 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; @@ -60,7 +62,7 @@ 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 CONTAIN_IMG_STYLE: CSSProperties = { display: 'block', height: '100%', objectFit: 'contain', width: '100%' }; /** Fit: the image fills the viewport; pan is in viewport pixels. */ interface CropView { @@ -71,6 +73,19 @@ interface CropView { 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 { + ...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)), + }; +}; + type PanState = 'idle' | 'ready' | 'panning'; const isDragMode = (value: unknown): value is ReferenceCropDragMode => @@ -121,7 +136,8 @@ const capturePointer = (element: HTMLElement, pointerId: number): void => { /** 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)); + // 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 { @@ -219,7 +235,7 @@ export const ReferenceImageCropDialog = ({ 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 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; @@ -284,10 +300,19 @@ export const ReferenceImageCropDialog = ({ return; } const onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) { + return; + } 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 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)) { @@ -319,7 +344,10 @@ export const ReferenceImageCropDialog = ({ 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 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]); @@ -331,10 +359,11 @@ export const ReferenceImageCropDialog = ({ if (!viewport || viewport.clientWidth === 0) { return; } - const zoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, original.width / viewport.clientWidth)); + const rect = viewport.getBoundingClientRect(); + const zoom = original.width / rect.width; setView({ - panX: (viewport.clientWidth * (1 - zoom)) / 2, - panY: (viewport.clientHeight * (1 - zoom)) / 2, + panX: (rect.width * (1 - zoom)) / 2, + panY: (rect.height * (1 - zoom)) / 2, zoom, }); }, [original.width]); @@ -359,15 +388,15 @@ export const ReferenceImageCropDialog = ({ const beginPan = useCallback( (event: PointerEvent) => { const viewport = viewportRef.current; - if (!viewport || dragRef.current || (panState !== 'ready' && event.button !== 1)) { + if (!viewport || dragRef.current || (event.button !== 1 && !(event.button === 0 && panState === 'ready'))) { return; } event.preventDefault(); capturePointer(viewport, event.pointerId); - panRef.current = { panX: view.panX, panY: view.panY, x: event.clientX, y: event.clientY }; + panRef.current = { x: event.clientX, y: event.clientY }; setPanState('panning'); }, - [panState, view.panX, view.panY] + [panState] ); const beginDrag = useCallback( @@ -392,12 +421,13 @@ export const ReferenceImageCropDialog = ({ const movePointer = useCallback( (event: PointerEvent) => { const pan = panRef.current; - if (pan) { - setView((current) => ({ - ...current, - panX: pan.panX + event.clientX - pan.x, - panY: pan.panY + event.clientY - pan.y, - })); + 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; } const stage = stageRef.current; @@ -545,13 +575,13 @@ export const ReferenceImageCropDialog = ({ bg="bg.muted" cursor={panState === 'panning' ? 'grabbing' : panState === 'ready' ? 'grab' : undefined} data-pan-state={panState} - maxH="60vh" + alignSelf="center" overflow="hidden" position="relative" rounded="md" touchAction="none" userSelect="none" - w="full" + w={`min(100%, ${60 * ratio}vh)`} onPointerCancel={endPointer} onPointerDown={beginPan} onPointerMove={movePointer} diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx index c5675594286..1ce2ff045ff 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx @@ -10,20 +10,24 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nextProvider, initReactI18next } from 'react-i18next'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { userEvent } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import { ReferenceImageCard } from './ReferenceImageCard'; const mocks = vi.hoisted(() => { // A real decodable image, so the crop export's loads without a backend. - const canvas = document.createElement('canvas'); - canvas.width = 512; - canvas.height = 512; - const context = canvas.getContext('2d')!; - context.fillStyle = '#4080c0'; - context.fillRect(0, 0, 512, 512); + const createImage = (width: number, height: number) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d')!; + context.fillStyle = '#4080c0'; + context.fillRect(0, 0, width, height); + return canvas.toDataURL('image/png'); + }; return { - imageDataUrl: canvas.toDataURL('image/png'), + createImage, + imageDataUrl: createImage(512, 512), upload: vi.fn(), }; }); @@ -71,8 +75,10 @@ const SQUARE_CROPPED_REFERENCE: GenerateReferenceImage = { }, }; -const renderCard = (referenceImage: GenerateReferenceImage) => - act(() => +const renderCard = (referenceImage: GenerateReferenceImage) => { + const original = referenceImage.config.image!.original.image; + mocks.imageDataUrl = mocks.createImage(original.width, original.height); + return act(() => root?.render( @@ -93,6 +99,7 @@ const renderCard = (referenceImage: GenerateReferenceImage) => ) ); +}; beforeEach(async () => { host = document.createElement('div'); @@ -104,13 +111,18 @@ beforeEach(async () => { afterEach(async () => { await act(() => root?.unmount()); host?.remove(); + await page.viewport(1280, 720); vi.clearAllMocks(); }); const openCropDialog = async (): Promise => { await act(() => userEvent.click(document.querySelector('button[aria-label="common.crop"]')!)); await expect.poll(() => document.querySelector('[role="dialog"]')?.getAttribute('data-state')).toBe('open'); - return document.querySelector('[role="dialog"]')!; + const dialog = document.querySelector('[role="dialog"]')!; + await expect + .poll(() => dialog.getAnimations().filter((animation) => animation.playState === 'running').length) + .toBe(0); + return dialog; }; /** The crop box as percentages of the image, read back from its inline style. */ @@ -188,11 +200,10 @@ describe('reference image crop dialog', () => { pointer('pointerup', viewport, startX + rect.width * 0.1, startY + rect.height * 0.3); }); - // The pointer shrank the width by 10% and the height by 30%; the ratio follows the larger candidate (the width), - // as the legacy cropper did, so the box always reaches the pointer. The opposite corner stays anchored. + // The 30% vertical shrink dominates after accounting for the ratio; the opposite corner stays anchored. const resized = readCropBox(dialog); expect(resized.width / resized.height).toBeCloseTo(9 / 16, 3); - expect(resized.width).toBeCloseTo(portrait.width - 10, 1); + expect(resized.height).toBeCloseTo(portrait.height - 30, 1); expect(resized.x + resized.width).toBeCloseTo(portrait.x + portrait.width, 1); expect(resized.y + resized.height).toBeCloseTo(100, 1); expect(dialog.textContent).toContain( @@ -279,6 +290,148 @@ describe('reference image crop dialog', () => { expect(readView(dialog)).toEqual({ x: 0, y: 0, zoom: 1 }); }); + it.each([ + { width: 1200, height: 600, viewportHeight: 720 }, + { width: 600, height: 1200, viewportHeight: 720 }, + { width: 800, height: 600, viewportHeight: 400 }, + ])('fits the stage and overlay to $width × $height at a $viewportHeight px viewport', async (size) => { + await page.viewport(1280, size.viewportHeight); + await renderCard({ + ...REFERENCE, + config: { + type: 'external_reference_image', + image: { original: { image: { width: size.width, height: size.height, image_name: 'original.png' } } }, + }, + }); + const dialog = await openCropDialog(); + const stage = dialog.querySelector('[data-crop-stage]')!; + const overlay = dialog.querySelector('[data-crop-box]')!; + const img = stage.querySelector('img')!; + await expect.poll(() => img.naturalWidth).toBe(size.width); + const fitted = stage.getBoundingClientRect(); + expect(fitted.width / fitted.height).toBeCloseTo(size.width / size.height, 2); + expect(fitted.height).toBeLessThanOrEqual(size.viewportHeight * 0.6 + 1); + expect(overlay.getBoundingClientRect().width).toBeCloseTo(fitted.width, 1); + expect(overlay.getBoundingClientRect().height).toBeCloseTo(fitted.height, 1); + + await act(() => + userEvent.click(dialog.querySelector('[aria-label="widgets.generate.cropActualSize"]')!) + ); + expect(stage.getBoundingClientRect().width).toBeCloseTo(size.width, 1); + expect(stage.getBoundingClientRect().height).toBeCloseTo(size.height, 1); + await act(() => userEvent.click(dialog.querySelector('[aria-label="widgets.generate.cropZoomIn"]')!)); + expect(stage.getBoundingClientRect().width).toBeCloseTo(size.width * 1.2, 1); + await act(() => userEvent.click(dialog.querySelector('[aria-label="widgets.generate.cropZoomOut"]')!)); + expect(stage.getBoundingClientRect().width).toBeCloseTo(size.width, 1); + await act(() => userEvent.click(dialog.querySelector('[aria-label="widgets.generate.cropFitView"]')!)); + expect(stage.getBoundingClientRect().width).toBeCloseTo(fitted.width, 1); + expect(readCropBox(dialog)).toEqual({ height: 100, width: 100, x: 0, y: 0 }); + }); + + it.each([ + { width: 64, height: 64, limitedControl: 'cropZoomOut', movingControl: 'cropZoomIn', factor: 1.2 }, + { width: 256, height: 8000, limitedControl: 'cropZoomIn', movingControl: 'cropZoomOut', factor: 1 / 1.2 }, + ])('keeps zoom controls monotonic after Actual size for a $width × $height image', async (size) => { + await renderCard({ + ...REFERENCE, + config: { + type: 'external_reference_image', + image: { original: { image: { width: size.width, height: size.height, image_name: 'original.png' } } }, + }, + }); + const dialog = await openCropDialog(); + const clickControl = (name: string) => + act(() => userEvent.click(dialog.querySelector(`[aria-label="widgets.generate.${name}"]`)!)); + const stage = dialog.querySelector('[data-crop-stage]')!; + await clickControl('cropActualSize'); + expect(stage.getBoundingClientRect().height).toBeCloseTo(size.height, 1); + await clickControl(size.limitedControl); + expect(stage.getBoundingClientRect().height).toBeCloseTo(size.height, 1); + await clickControl(size.movingControl); + expect(stage.getBoundingClientRect().height).toBeCloseTo(size.height * size.factor, 1); + }); + + it('ignores horizontal wheels and normalizes proportional pixel, line and page zoom', async () => { + const dialog = await openCropDialog(); + const viewport = viewportOf(dialog); + const rect = viewport.getBoundingClientRect(); + const wheel = (deltaY: number, deltaMode = 0) => + act(() => { + viewport.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + deltaX: 50, + deltaY, + deltaMode, + }) + ); + }); + await wheel(0); + expect(readView(dialog)).toEqual({ x: 0, y: 0, zoom: 1 }); + await wheel(-1); + expect(readView(dialog).zoom).toBeCloseTo(1.1 ** 0.01, 5); + await wheel(1); + expect(readView(dialog).zoom).toBeCloseTo(1, 6); + await wheel(-6.25, 1); + expect(readView(dialog).zoom).toBeCloseTo(1.1, 6); + await wheel(100 / rect.height, 2); + expect(readView(dialog).zoom).toBeCloseTo(1, 6); + }); + + it('keeps panning continuous across wheel zoom, bounds the view, and ignores Space plus right click', async () => { + const dialog = await openCropDialog(); + const viewport = viewportOf(dialog); + const rect = viewport.getBoundingClientRect(); + await act(() => { + spaceKey('keydown'); + }); + await act(() => { + pointer('pointerdown', viewport, rect.left + 100, rect.top + 100, 2); + pointer('pointermove', viewport, rect.left + 150, rect.top + 150, 2); + pointer('pointerup', viewport, rect.left + 150, rect.top + 150, 2); + }); + expect(readView(dialog)).toEqual({ x: 0, y: 0, zoom: 1 }); + await act(() => { + pointer('pointerdown', viewport, rect.left + 100, rect.top + 100); + pointer('pointermove', viewport, rect.left + 120, rect.top + 120); + }); + await act(() => { + viewport.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: rect.left + 120, + clientY: rect.top + 120, + deltaY: -100, + }) + ); + }); + const zoomed = readView(dialog); + await act(() => { + pointer('pointermove', viewport, rect.left + 121, rect.top + 121); + }); + expect(readView(dialog).x).toBeCloseTo(zoomed.x + 1, 5); + expect(readView(dialog).y).toBeCloseTo(zoomed.y + 1, 5); + await act(() => { + pointer('pointermove', viewport, rect.left + 10000, rect.top + 10000); + }); + expect(readView(dialog).x).toBeCloseTo(rect.width - 48, 2); + expect(readView(dialog).y).toBeCloseTo(rect.height - 48, 2); + await act(() => { + pointer('pointermove', viewport, rect.left - 10000, rect.top - 10000); + }); + const stage = dialog.querySelector('[data-crop-stage]')!.getBoundingClientRect(); + expect(stage.right - rect.left).toBeCloseTo(48, 2); + expect(stage.bottom - rect.top).toBeCloseTo(48, 2); + await act(() => { + pointer('pointerup', viewport, rect.left - 10000, rect.top - 10000); + spaceKey('keyup'); + }); + }); + it('leaves Space to a focused button, so Apply still activates from the keyboard', async () => { const dialog = await openCropDialog(); const apply = [...dialog.querySelectorAll('button')].find((button) => button.textContent === 'common.apply')!; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md index c0d644b0af3..6b6e24d5f82 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## Ownership invariant -The workbench reducer owns the serializable canvas contract. A per-project canvas engine owns pixels, decoded images, rendering, interaction, history, transient editing state, and caller-owned raster snapshots. Engine-owned objects never enter reducer actions or persisted project state. +The workbench reducer owns the serializable canvas contract. A per-project canvas engine owns pixels, decoded images, rendering, interaction, history, transient editing state, and caller-owned raster snapshots. Engine-owned objects never enter reducer actions or persisted project state. Eraser options and layer quick-switch memory belong to this engine lifetime, survive cooldown only while the registry retains that engine, and reset on disposal/recreation. Quick-switch memory updates its return target only on explicit non-null selection changes outside engine edits; deselection, temporary engine selections, and removed targets do not overwrite it. The next explicit switch records the actual layer being left, including an automatic selection after an edit. Canvas mutations are explicitly project-addressed. `CanvasProjectMutation` is the complete reducer mutation vocabulary, and the only `WorkbenchAction` that carries one is: diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts index 131507814ec..e257044e1ee 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -44,6 +44,7 @@ import { } from '@workbench/canvas-operations/createCanvasEngine'; import { FILTER_AUTO_PROCESS_DEBOUNCE_MS } from '@workbench/canvas-operations/filterOperationSession'; import { getCanvasOperationImplementation as getCanvasOperations } from '@workbench/canvas-operations/operationAccess'; +import { executeCanvasHotkeyCommand } from '@workbench/widgets/canvas/canvasHotkeyCommands'; import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState.testing'; import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; @@ -16660,6 +16661,23 @@ describe('text edit session', () => { engine.lifecycle.dispose(); }); + it('does not replace quick-switch memory when text editing selects its target', () => { + const document = textDoc(); + const withRaster = { + ...document, + stacks: stacksFrom([...getDocumentLeaves(document), rasterLayer('a'), rasterLayer('b')]), + selectedLayerId: 'a', + }; + const { engine, setDocument } = makeEngine(withRaster); + setDocument({ ...withRaster, selectedLayerId: 'b' }); + engine.layers.openTextEdit('txt1'); + expect(engine.document.getDocument()?.selectedLayerId).toBe('txt1'); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); + setDocument({ ...withRaster, selectedLayerId: 'a' }); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('txt1'); + engine.lifecycle.dispose(); + }); + it('opening an edit on the selected text layer dispatches nothing', () => { const { dispatch, engine } = makeEngine(textDoc()); engine.tools.setTool('text'); @@ -17332,7 +17350,10 @@ describe('Select Object canvas engine integration', () => { expect(getCanvasOperations(h.engine).startSelectObject('first')).toBe('started'); expect(h.store.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'setCanvasSelectedLayer' })); + const memory = { bookmarkedLayerId: 'first', previousLayerId: 'second' }; + h.engine.interaction.set('layerQuickSwitch', memory); expect(getCanvasOperations(h.engine).startSelectObject('second')).toBe('started'); + expect(h.engine.interaction.get('layerQuickSwitch')).toEqual(memory); expect(h.store.dispatch).toHaveBeenCalledWith({ id: 'second', type: 'setCanvasSelectedLayer' }); h.engine.lifecycle.dispose(); }); @@ -18930,6 +18951,127 @@ describe('floating selection lifecycle', () => { }); describe('layer quick-switch memory', () => { + const pressQuickSwitch = (engine: CanvasEngine, mutationPort: CanvasProjectMutationPort): void => { + const document = engine.document.getDocument()!; + executeCanvasHotkeyCommand('canvas.quickSwitch', { + document, + engine, + dispatch: (mutation) => mutationPort.dispatch(mutation, 'user'), + selectedLayerIds: document.selectedLayerId ? [document.selectedLayerId] : [], + hasStagingSlots: false, + hasSelectedStagedCandidate: false, + isInteractionLocked: false, + copySelection: vi.fn(), + swapActiveColors: vi.fn(), + resetActiveColors: vi.fn(), + pasteFromClipboard: vi.fn(), + notifyLayerDuplicateFailed: vi.fn(), + reportStructuralCommit: vi.fn(), + reportPreparedCommit: vi.fn(), + reportLayerOperation: vi.fn(), + t: (key) => key, + }); + }; + it.each(['external repair', 'engine removal'] as const)('keeps a surviving previous layer after %s', (removal) => { + const document = { + ...makeDoc(), + stacks: stacksFrom([rasterLayer('a'), rasterLayer('b'), rasterLayer('c')]), + selectedLayerId: 'a', + }; + const { setDocument, store } = createReactiveStore(document); + const mutationPort = createTestMutationPort(store, 'p1'); + const engine = createCanvasEngine({ + mutationPort, + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + setDocument({ ...document, selectedLayerId: 'b' }); + if (removal === 'external repair') { + setDocument({ ...document, stacks: stacksFrom([rasterLayer('a'), rasterLayer('c')]), selectedLayerId: 'c' }); + } else { + const edit = engine.document.model()!.prepare({ type: 'remove', ids: ['b'] }); + if (edit.status !== 'prepared') { + throw new Error('Expected prepared removal'); + } + expect(engine.layers.commitPrepared('Delete layer', edit.edit).status).toBe('committed'); + } + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); + expect(engine.document.getDocument()?.selectedLayerId).toBe('c'); + const history = engine.history.getEntries(); + pressQuickSwitch(engine, mutationPort); + expect(engine.document.getDocument()?.selectedLayerId).toBe('a'); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('c'); + pressQuickSwitch(engine, mutationPort); + expect(engine.document.getDocument()?.selectedLayerId).toBe('c'); + expect(engine.history.getEntries()).toEqual(history); + engine.lifecycle.dispose(); + }); + + it('returns from a bookmark to the automatically selected new text layer', () => { + const document = { ...makeDoc(), stacks: stacksFrom([rasterLayer('a'), rasterLayer('b')]), selectedLayerId: 'a' }; + const { setDocument, store } = createReactiveStore(document); + const mutationPort = createTestMutationPort(store, 'p1'); + const engine = createCanvasEngine({ + mutationPort, + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + setDocument({ ...document, selectedLayerId: 'b' }); + engine.layers.openTextCreate({ x: 10, y: 20 }); + engine.layers.commitTextEdit('Text'); + const textId = engine.document.getDocument()!.selectedLayerId!; + expect(textId).not.toBe('a'); + expect(textId).not.toBe('b'); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); + engine.interaction.set('layerQuickSwitch', { + ...engine.interaction.get('layerQuickSwitch'), + bookmarkedLayerId: 'a', + }); + const history = engine.history.getEntries(); + pressQuickSwitch(engine, mutationPort); + expect(engine.document.getDocument()?.selectedLayerId).toBe('a'); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe(textId); + pressQuickSwitch(engine, mutationPort); + expect(engine.document.getDocument()?.selectedLayerId).toBe(textId); + expect(engine.history.getEntries()).toEqual(history); + engine.lifecycle.dispose(); + }); + + it('ignores the temporary paint layer and its cancellation rollback', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const document = { + ...makeDoc(), + stacks: stacksFrom([rasterLayer('a'), rasterLayer('b'), rasterLayer('c')]), + selectedLayerId: 'a', + }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + setDocument({ ...document, selectedLayerId: 'b' }); + const overlay = createInputCanvas(); + engine.surface.attach(createInputCanvas().element, overlay.element); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + expect(engine.document.getDocument()?.selectedLayerId).not.toBe('b'); + engine.tools.setTool('move'); + expect(engine.document.getDocument()?.selectedLayerId).toBe('b'); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); + expect(engine.history.getEntries().past).toEqual([]); + engine.lifecycle.dispose(); + }); + it('remembers the previously selected layer across selection changes, keeping it through a deselect', () => { const document = { ...makeDoc(), stacks: stacksFrom([rasterLayer('a'), rasterLayer('b')]), selectedLayerId: 'a' }; const { setDocument, store } = createReactiveStore(document); @@ -18944,8 +19086,11 @@ describe('layer quick-switch memory', () => { setDocument({ ...document, selectedLayerId: 'b' }); expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); setDocument({ ...document, selectedLayerId: null }); - expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('b'); - // Reselecting after a deselect records nothing new: there was no layer to return to. + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); + setDocument({ ...document, selectedLayerId: 'b' }); + expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('a'); + // A different explicit choice after deselect still returns to the last non-null selection. + setDocument({ ...document, selectedLayerId: null }); setDocument({ ...document, selectedLayerId: 'a' }); expect(engine.interaction.get('layerQuickSwitch').previousLayerId).toBe('b'); @@ -18956,6 +19101,7 @@ describe('layer quick-switch memory', () => { }); setDocument({ ...document, selectedLayerId: 'b' }); expect(engine.interaction.get('layerQuickSwitch')).toEqual({ bookmarkedLayerId: 'b', previousLayerId: 'a' }); + expect(engine.history.getEntries().past).toEqual([]); engine.lifecycle.dispose(); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index be3aa1dfc35..a20b48b5370 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -125,7 +125,12 @@ import { createDocumentModel, type CanvasDocumentModel, } from '@workbench/canvas-engine/document-model/documentModel'; -import { getDocumentIndex, getDocumentLayer, getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; +import { + getDocumentIndex, + getDocumentLayer, + getDocumentLeaves, + getDocumentNode, +} from '@workbench/canvas-engine/document/documentIndex'; import { createEngineStores, type EngineStores, @@ -651,8 +656,17 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC }); const history = historyController.history; const unsubscribeHistoryEpoch = history.subscribe(() => stores.historyEpoch.set(stores.historyEpoch.get() + 1)); - const dispatchCanvasMutation = (action: CanvasProjectMutation, origin: 'system' | 'user' = 'user'): boolean => - mutationPort.dispatch(action, origin); + let engineMutationDepth = 0; + const dispatchCanvasMutation = (action: CanvasProjectMutation, origin: 'system' | 'user' = 'user'): boolean => { + // Engine edits can select temporary layers, replay selections, or repair them on rollback. They are not + // explicit layer navigation, even when the edit itself is user-originated. + engineMutationDepth += 1; + try { + return mutationPort.dispatch(action, origin); + } finally { + engineMutationDepth -= 1; + } + }; // Direct pixel writes do not replace the reducer canvas object. Snapshot // freshness therefore also binds to this engine-local direct-pixel epoch. let directPixelEpoch = 0; @@ -1447,7 +1461,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // ---- Document mirror ---------------------------------------------------- - /** The selection the mirror last reported; it feeds the Q quick-switch memory on the next change. */ + /** Actual last non-null selection, retained across deselect/reselect and updated by engine selections too. */ let lastSelectedLayerId: string | null = null; const mirror: DocumentMirror = createDocumentMirror(mutationPort, { onDocumentChanged: () => stores.documentEpoch.set(stores.documentEpoch.get() + 1), @@ -1592,11 +1606,20 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC */ onSelectionChanged: (selectedLayerId) => { const cleanup = createCleanupAccumulator(); - // Q returns to the layer selected before this one; deselecting keeps the last real selection as previous. - if (lastSelectedLayerId !== null) { - stores.layerQuickSwitch.set({ ...stores.layerQuickSwitch.get(), previousLayerId: lastSelectedLayerId }); + if (selectedLayerId !== null && selectedLayerId !== lastSelectedLayerId) { + // A deletion/merge may choose a replacement after removing the current layer. Keep the surviving + // previous choice instead of replacing it with the removed id. + if ( + engineMutationDepth === 0 && + lastSelectedLayerId !== null && + getDocumentNode(mirror.getDocument(), lastSelectedLayerId) + ) { + stores.layerQuickSwitch.set({ ...stores.layerQuickSwitch.get(), previousLayerId: lastSelectedLayerId }); + } + // Internal selections do not change the return target, but the next explicit switch must return + // to the layer actually being left (including a deletion fallback or an automatically selected layer). + lastSelectedLayerId = selectedLayerId; } - lastSelectedLayerId = selectedLayerId; // A float belongs to exactly one layer; selecting another banks it rather // than leaving pixels in flight over a layer the user is no longer on. cleanup.run(() => floatingSelection.commit()); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts index f900270726e..78890b98504 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts @@ -59,7 +59,7 @@ export interface EraserOptions { export interface LayerQuickSwitchState { /** Set from the layer menu; a stale id (the layer was removed) is ignored by readers. */ bookmarkedLayerId: string | null; - /** Engine-maintained: the previous non-null selection, once the selection has changed. */ + /** Engine-maintained: the layer left by the last explicit switch; internal edits and deselection preserve it. */ previousLayerId: string | null; } diff --git a/invokeai/frontend/webv2/src/workbench/layerPanelState.test.ts b/invokeai/frontend/webv2/src/workbench/layerPanelState.test.ts index 05da1c0be2b..0881156012d 100644 --- a/invokeai/frontend/webv2/src/workbench/layerPanelState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/layerPanelState.test.ts @@ -229,12 +229,24 @@ describe('layer panel state store', () => { }); describe('revealing an external primary', () => { + it('keeps a filter when the selected layer or a selected group descendant matches', () => { + const index = indexStacks( + stacksFrom([groupContract('g', [layerContract('a', 'raster', { name: 'Sky' })]), layerContract('b')]) + ); + const state = createLayerPanelState('project', 'b', { filter: ' sky ' }); + expect(reconcileLayerPanelState(state, 'project', index, 'a').filter).toBe(' sky '); + expect(reconcileLayerPanelState(state, 'project', index, 'g').filter).toBe(' sky '); + }); + it('expands every group above a primary that arrived from outside the panel', () => { const index = indexStacks( stacksFrom([groupContract('g', [groupContract('h', [layerContract('a')])]), layerContract('b')]) ); - expect(reconcileLayerPanelState(createLayerPanelState('project', 'b'), 'project', index, 'a')).toMatchObject({ + expect( + reconcileLayerPanelState(createLayerPanelState('project', 'b', { filter: 'b' }), 'project', index, 'a') + ).toMatchObject({ expandedGroupIds: ['g', 'h'], + filter: '', primaryId: 'a', selectedIds: ['a'], }); diff --git a/invokeai/frontend/webv2/src/workbench/layerPanelState.ts b/invokeai/frontend/webv2/src/workbench/layerPanelState.ts index 01e71e23b0d..c58b1e48e09 100644 --- a/invokeai/frontend/webv2/src/workbench/layerPanelState.ts +++ b/invokeai/frontend/webv2/src/workbench/layerPanelState.ts @@ -212,8 +212,20 @@ export const reconcileLayerPanelState = ( } // The new primary is revealed: its stack opens and every group on its path expands. const carry = carryOf(state); + const filter = carry.filter.trim().toLocaleLowerCase(); + // A group is also visible when a matching descendant keeps its ancestor path in the filtered tree. + const filterHidesPrimary = + primary && + filter !== '' && + !index.nodes.some( + (entry) => + (entry.node.id === validPrimaryId || entry.path.includes(validPrimaryId!)) && + entry.node.name.toLocaleLowerCase().includes(filter) + ); return createLayerPanelState(projectId, validPrimaryId, { ...carry, + // An external navigation must reveal its target even when a name filter hid it. + filter: filterHidesPrimary ? '' : carry.filter, collapsedChildLayerIds: carry.collapsedChildLayerIds.filter((id) => index.byId.has(id)), collapsedStacks: primary ? carry.collapsedStacks.filter((stack) => stack !== primary.stack) diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerSurfaceHost.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerSurfaceHost.browser.test.tsx index b8b1e133f04..e6cd9ddd1a5 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerSurfaceHost.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerSurfaceHost.browser.test.tsx @@ -74,12 +74,21 @@ const nodes: CanvasNodeContract[] = [ layerContract('layer', 'raster', { name: 'Layer' }), groupContract('group', [layerContract('child', 'raster', { name: 'Child' })], { name: 'Group' }), ]; +const quickSwitch = { bookmarkedLayerId: null as string | null, previousLayerId: 'child' as string | null }; const engine = { document: { model: () => createDocumentModel(mocks.project.canvas.document, { editRevision: 0, projectId: PROJECT_ID }), }, exports: { hasExportableLayerContent: () => true }, - interaction: { get: () => false, subscribe: () => () => undefined }, + interaction: { + get: (key: string) => (key === 'layerQuickSwitch' ? quickSwitch : false), + set: (key: string, value: typeof quickSwitch) => { + if (key === 'layerQuickSwitch') { + Object.assign(quickSwitch, value); + } + }, + subscribe: () => () => undefined, + }, layers: { commitPrepared: () => ({ status: 'committed' as const }), endStructuralPreview: () => undefined }, projectId: PROJECT_ID, } as unknown as LayerSurfaceEngine; @@ -111,6 +120,8 @@ let root: Root | null = null; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; beforeEach(async () => { + quickSwitch.bookmarkedLayerId = null; + quickSwitch.previousLayerId = 'child'; const initial = { ...createInitialWorkbenchState().projects[0]!, id: PROJECT_ID }; mocks.project = applyCanvasProjectMutation(initial, { document: { ...createEmptyCanvasDocument(), selectedLayerId: null, stacks: stacksFrom(nodes) }, @@ -272,3 +283,13 @@ it('offers an adjustment every other raster layer and group the model accepts un await act(() => page.getByRole('menuitem', { name: 'Group', exact: true }).click()); expect(moveChildToLayer).toHaveBeenCalledWith(child, 'group'); }); + +it('toggles the real bookmark menu handler without changing previous-layer memory', async () => { + await act(() => openSurface({ anchor, id: 'layer', kind: 'menu' })); + await act(() => page.getByRole('menuitem', { name: /^Bookmark for quick switch/ }).click()); + expect(quickSwitch).toEqual({ bookmarkedLayerId: 'layer', previousLayerId: 'child' }); + await expect.poll(openMenu).toBeNull(); + await act(() => openSurface({ anchor, id: 'layer', kind: 'menu' })); + await act(() => page.getByRole('menuitem', { name: /^Remove quick switch bookmark/ }).click()); + expect(quickSwitch).toEqual({ bookmarkedLayerId: null, previousLayerId: 'child' }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.browser.test.tsx index 4936de143ef..72febccb004 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.browser.test.tsx @@ -1,5 +1,5 @@ /* oxlint-disable react-perf/jsx-no-new-function-as-prop */ -import type { CanvasNodeContract, PreparedDocumentEdit } from '@workbench/canvas-engine/api'; +import type { CanvasEngine, CanvasNodeContract, PreparedDocumentEdit } from '@workbench/canvas-engine/api'; import type { CanvasOperationState } from '@workbench/canvas-operations/api'; import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; import type { Project } from '@workbench/projectContracts'; @@ -28,6 +28,7 @@ import { toggleLayerStackCollapsed, useLayerPanelState, } from '@workbench/layerPanelState'; +import { executeCanvasHotkeyCommand, type CanvasHotkeyContext } from '@workbench/widgets/canvas/canvasHotkeyCommands'; import { createInitialWorkbenchState } from '@workbench/workbenchState'; import { createInstance } from 'i18next'; import { act, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; @@ -616,6 +617,54 @@ describe('LayersTree selection, surfaces and structure', () => { expect(output('selected-layer')).toBe('third'); }); + it('reveals a cycled layer through collapsed ancestors and a filter without opening properties', async () => { + await renderTree([ + ...manyLayers(60), + groupContract('outer', [groupContract('inner', [layerContract('target', 'raster', { name: 'Target' })])]), + ]); + await act(() => dispatchExternal({ id: 'l59', type: 'setCanvasSelectedLayer' })); + await act(() => { + setLayerPanelFilter(PROJECT_ID, 'l59', 'Layer 59'); + toggleLayerStackCollapsed(PROJECT_ID, 'l59', 'raster'); + }); + const ctx: CanvasHotkeyContext = { + document: harnessDocument, + engine: engine as CanvasEngine, + dispatch: (mutation) => { + dispatchExternal(mutation); + return true; + }, + selectedLayerIds: ['l59'], + hasStagingSlots: false, + hasSelectedStagedCandidate: false, + isInteractionLocked: false, + copySelection: vi.fn(), + swapActiveColors: vi.fn(), + resetActiveColors: vi.fn(), + pasteFromClipboard: vi.fn(), + notifyLayerDuplicateFailed: vi.fn(), + reportStructuralCommit: vi.fn(), + reportPreparedCommit: vi.fn(), + reportLayerOperation: vi.fn(), + t: (key) => key, + }; + await act(() => executeCanvasHotkeyCommand('canvas.nextEntity', ctx)); + await settle(); + expect(output('selected-layer')).toBe('target'); + expect(readLayerPanelState(PROJECT_ID, 'target')).toMatchObject({ + expandedGroupIds: ['outer', 'inner'], + collapsedStacks: [], + filter: '', + }); + const row = treeitem('Target'); + const scroller = host!.querySelector('[role="tree"]')!.closest('[data-part="viewport"]')!; + expect(scroller.scrollTop).toBeGreaterThan(0); + expect(row.getBoundingClientRect().top).toBeGreaterThanOrEqual(scroller.getBoundingClientRect().top); + expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(scroller.getBoundingClientRect().bottom); + expect(output('surface')).toBe('none'); + expect(revealRequests).not.toHaveBeenCalled(); + }); + it('reveals a primary that changed outside the panel once, even inside a collapsed stack', async () => { await renderTree([layerContract('c1', 'control', { name: 'Control' }), ...manyLayers(2000)]); const scroller = host!.querySelector('[role="tree"]')!.closest('[data-part="viewport"]')!; From 664bd93f48ea4e50155d91c29b07c6cdf59696ee Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 17 Oct 2026 00:56:05 -0600 Subject: [PATCH 4/4] fix(generation): label actual-size zoom as 1:1 Distinguish actual-size zoom from Fit image while preserving its tooltip and accessible name. --- .../reference-images/ReferenceImageCropDialog.tsx | 15 +++++++++++++-- .../referenceImageCrop.browser.test.tsx | 10 ++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) 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 6e17d59f811..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 @@ -32,7 +32,7 @@ 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 { MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -565,7 +565,18 @@ export const ReferenceImageCropDialog = ({ /> - + + + diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx index 1ce2ff045ff..156c2152b9d 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/referenceImageCrop.browser.test.tsx @@ -315,7 +315,7 @@ describe('reference image crop dialog', () => { expect(overlay.getBoundingClientRect().height).toBeCloseTo(fitted.height, 1); await act(() => - userEvent.click(dialog.querySelector('[aria-label="widgets.generate.cropActualSize"]')!) + userEvent.click(dialog.querySelector('[aria-label="1:1 widgets.generate.cropActualSize"]')!) ); expect(stage.getBoundingClientRect().width).toBeCloseTo(size.width, 1); expect(stage.getBoundingClientRect().height).toBeCloseTo(size.height, 1); @@ -341,7 +341,13 @@ describe('reference image crop dialog', () => { }); const dialog = await openCropDialog(); const clickControl = (name: string) => - act(() => userEvent.click(dialog.querySelector(`[aria-label="widgets.generate.${name}"]`)!)); + act(() => + userEvent.click( + dialog.querySelector( + `[aria-label="${name === 'cropActualSize' ? '1:1 ' : ''}widgets.generate.${name}"]` + )! + ) + ); const stage = dialog.querySelector('[data-crop-stage]')!; await clickControl('cropActualSize'); expect(stage.getBoundingClientRect().height).toBeCloseTo(size.height, 1);