-
-
-
- {showCanvas ?
: null}
+
+
+
+
+
+ {showCanvas ? : null}
+
-
+
);
};
@@ -344,3 +372,149 @@ describe('CanvasSurface keyboard ownership', () => {
await userEvent.keyboard('{/Space}');
});
});
+
+/** The shell's layout in miniature: the canvas, the Generate panel and the Layers panel, each a focus region. */
+const WorkbenchHarness = ({ engine }: { engine: CanvasEngine }) => (
+
+
+
+
+
+
+
+
+ Model
+
+
+
+
+
+
+
+);
+
+const Region = ({
+ children,
+ instanceId,
+ region,
+ typeId,
+}: {
+ children: ReactNode;
+ instanceId: string;
+ region: 'center' | 'left' | 'right';
+ typeId: string;
+}) => (
+
+ {children}
+
+);
+
+describe('CanvasSurface key owner', () => {
+ const mount = async () => {
+ const registry = createTrackedRegistry(20);
+ const projectId = workbench.store.getSnapshot().activeProject.id;
+ const { engine } = acquireTrackedEngine(registry, projectId, createEngineDeps(createEmptyCanvasState(64, 64)));
+ const host = document.createElement('div');
+ document.body.append(host);
+ const { root } = createTrackedRoot(host);
+ await act(async () => {
+ root.render(
);
+ await nextFrame();
+ });
+ const overlay = host.querySelectorAll('canvas')[1]!;
+ return {
+ clickCanvas: () => userEvent.click(overlay, { position: { x: 4, y: 4 } }),
+ clickGenerate: () => userEvent.click(host.querySelector('span')!),
+ clickLayersRow: () => userEvent.click(host.querySelector('[role="treeitem"]')!),
+ engine,
+ hasSelection: () => engine.interaction.get('hasSelection'),
+ overlay,
+ prompt: host.querySelector('input')!,
+ tool: () => engine.interaction.get('activeTool'),
+ };
+ };
+
+ it('keeps Escape and C from the canvas after a click in another widget', async () => {
+ const view = await mount();
+ view.engine.tools.setTool('view');
+ view.engine.selection.selectAll();
+ await view.clickGenerate();
+
+ await userEvent.keyboard('{Escape}');
+ await userEvent.keyboard('c');
+ expect(view.hasSelection()).toBe(true);
+ expect(view.tool()).toBe('view');
+ });
+
+ it('lets Escape and C act from the canvas and from a Layers row', async () => {
+ const view = await mount();
+ view.engine.tools.setTool('view');
+ view.engine.selection.selectAll();
+ await view.clickLayersRow();
+
+ await userEvent.keyboard('{Escape}');
+ expect(view.hasSelection()).toBe(false);
+ // Away from the canvas, a C tap selects the frame tool.
+ await userEvent.keyboard('c');
+ expect(view.tool()).toBe('bbox');
+
+ view.engine.tools.setTool('view');
+ view.engine.selection.selectAll();
+ await view.clickCanvas();
+ await userEvent.keyboard('{Escape}');
+ expect(view.hasSelection()).toBe(false);
+ await userEvent.keyboard('{c>}');
+ expect(view.tool()).toBe('bbox');
+ await userEvent.keyboard('{/c}');
+ });
+
+ it('applies Enter only from the canvas itself', async () => {
+ const view = await mount();
+ view.engine.tools.setTool('lasso');
+ view.engine.interaction.set('lassoOptions', { ...view.engine.interaction.get('lassoOptions'), shape: 'polygon' });
+ for (const position of [
+ { x: 20, y: 20 },
+ { x: 120, y: 20 },
+ { x: 120, y: 100 },
+ ]) {
+ await userEvent.click(view.overlay, { position });
+ }
+
+ await view.clickLayersRow();
+ await userEvent.keyboard('{Enter}');
+ await view.clickGenerate();
+ await userEvent.keyboard('{Enter}');
+ expect(view.hasSelection()).toBe(false);
+
+ await userEvent.click(view.overlay, { position: { x: 20, y: 100 } });
+ await userEvent.keyboard('{Enter}');
+ expect(view.hasSelection()).toBe(true);
+ });
+
+ it('keeps the Space hold over the hovered canvas after a click elsewhere, and never takes typing', async () => {
+ const view = await mount();
+ view.engine.tools.setTool('brush');
+ await view.clickGenerate();
+
+ await userEvent.hover(view.overlay);
+ await userEvent.keyboard('{Space>}');
+ expect(view.tool()).toBe('view');
+ await userEvent.keyboard('{/Space}');
+ expect(view.tool()).toBe('brush');
+
+ await userEvent.click(view.prompt);
+ await userEvent.hover(view.overlay);
+ await userEvent.keyboard('c');
+ expect(view.prompt.value).toBe('c');
+ expect(view.tool()).toBe('brush');
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx
index be106f52faa..fe7e2dd7e47 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx
@@ -1,16 +1,21 @@
/* oxlint-disable react-perf/jsx-no-new-function-as-prop -- the container ref callback is intentionally re-created when `engine` changes, so a project switch detaches the old engine and attaches the new one. */
+import type { CanvasKeyOwner } from '@workbench/canvas-engine/api';
import type { CanvasEngineHandle } from '@workbench/canvas-operations/react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { Box } from '@chakra-ui/react';
import { isResizeDragActive, subscribeResizeDrag } from '@platform/ui/ResizeHandle';
+import { useWorkbenchFocusTarget } from '@workbench/focusRegions';
+import { getHotkeyTargetWidget, resolveHotkeyTarget } from '@workbench/hotkeys/targetWidget';
+import { getWidgetPlacementProject } from '@workbench/widgetPlacementMeta';
import { shouldFocusCanvasSurface } from '@workbench/widgets/canvas/surfaceFocus';
import { TextEditPortal } from '@workbench/widgets/canvas/TextEditPortal';
+import { useWorkbenchQueries } from '@workbench/WorkbenchContext';
import { useRef } from 'react';
export type CanvasSurfaceEngine = Pick<
CanvasEngineHandle,
- 'document' | 'interaction' | 'layers' | 'surface' | 'viewport'
+ 'document' | 'interaction' | 'layers' | 'projectId' | 'surface' | 'viewport'
>;
/**
@@ -27,9 +32,30 @@ const focusCanvasSurface = (event: ReactPointerEvent
) => {
* Bind document/overlay canvases and ResizeObserver through an engine-keyed ref callback with cleanup. The engine
* owns input without React interaction renders.
*/
-export const CanvasSurface = ({ engine }: { engine: CanvasSurfaceEngine }) => {
+export const CanvasSurface = ({ engine, instanceId }: { engine: CanvasSurfaceEngine; instanceId: string }) => {
const screenRef = useRef(null);
const overlayRef = useRef(null);
+ const getFocusTarget = useWorkbenchFocusTarget();
+ const queries = useWorkbenchQueries();
+
+ // The engine's own keys ask the judge the hotkey runtime asks, with the same inputs, only on keydown: this canvas
+ // owns the key, the Layers widget acting on its layers is a companion, and any other widget keeps it.
+ const resolveKeyOwner = (event: KeyboardEvent): CanvasKeyOwner => {
+ const project = queries.getSnapshot().activeProject;
+ if (project.id !== engine.projectId) {
+ return 'elsewhere';
+ }
+ const target = resolveHotkeyTarget({
+ focusTarget: getFocusTarget(),
+ project: getWidgetPlacementProject(project),
+ targetWidget: getHotkeyTargetWidget(event.target),
+ });
+ return target.activeInstanceId === instanceId
+ ? 'surface'
+ : target.activeWidgetTypeId === 'layers'
+ ? 'companion'
+ : 'elsewhere';
+ };
const bindContainer = (container: HTMLDivElement) => {
const screen = screenRef.current;
@@ -38,7 +64,7 @@ export const CanvasSurface = ({ engine }: { engine: CanvasSurfaceEngine }) => {
return;
}
- engine.surface.attach(screen, overlay, container);
+ engine.surface.attach(screen, overlay, container, { resolveKeyOwner });
// A handle drag defers the resize and full recomposition to its release; pixel CSS sizes make the interim
// canvas crop or reveal rather than stretch.
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx
index dee57607ab4..1f9a321dcf2 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx
@@ -39,14 +39,16 @@ import { useModelGridSize } from './bboxGrid';
import { CanvasBottomOverlay } from './CanvasBottomOverlay';
import { decodeImageBlob, readClipboardImage } from './canvasClipboard';
import {
+ cropAllLayersToFrame,
resolveCanvasContextMenu,
resolveCanvasContextMenuBranch,
type CanvasContextMenuTarget,
} from './canvasContextMenu';
import { CanvasCreateFromBboxSubmenu } from './CanvasCreateFromBboxSubmenu';
+import { CanvasCropAllLayersMenuItem } from './CanvasCropAllLayersMenuItem';
import { CanvasEditRefusalNotices } from './CanvasEditRefusalNotices';
import { CanvasGlobalContextMenu } from './CanvasGlobalContextMenu';
-import { executeCanvasHotkeyCommand } from './canvasHotkeyCommands';
+import { CANVAS_ONLY_HOTKEYS, executeCanvasHotkeyCommand } from './canvasHotkeyCommands';
import { resolveCanvasImageDrop } from './canvasImageDnd';
import { CanvasImageDropOverlay } from './CanvasImageDropOverlay';
import { createCanvasInteractionLockReader, getCanvasInteractionCapabilities } from './canvasInteractionLock';
@@ -59,6 +61,7 @@ import {
} from './canvasSettings';
import { CanvasSurface } from './CanvasSurface';
import { CanvasSurfaceContextLayout } from './CanvasSurfaceContextLayout';
+import { CANVAS_COMPANION_HOTKEYS, reportTransformRefusal } from './canvasToolCommands';
import { resolveCheckerColors } from './checkerColors';
import { CanvasColorFeed } from './color-system/CanvasColorFeed';
import { useActiveColorCommands } from './color-system/useActiveColors';
@@ -509,6 +512,11 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => {
const selectedLayerIds = readLayerPanelState(projectId, document.selectedLayerId).selectedIds;
executeCanvasHotkeyCommand(commandId, {
copySelection,
+ cropAllLayers: () => {
+ if (engine) {
+ void cropAllLayersToFrame(engine.layers, notify, t);
+ }
+ },
dispatch: canvasDispatch,
document,
engine,
@@ -519,6 +527,7 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => {
notifications.add({ kind: 'error', title: t('widgets.layers.actions.copyFailed') }),
pasteFromClipboard,
reportLayerOperation: (refusal) => reportLayerOperation(refusal, notify.error, t),
+ reportTransformRefusal: (refusal) => reportTransformRefusal(refusal, notify.error, t),
reportPreparedCommit: (outcome) => reportPreparedCommit(outcome, notify.error, t),
reportStructuralCommit: (result) => reportStructuralCommit(result, notify.error, t),
resetActiveColors: colorCommands.resetPair,
@@ -529,64 +538,14 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => {
});
useEffect(() => {
- const hotkeys = [
- // Staging keeps `alt+[` / `alt+]`; bare left/right are registered as layer nudges,
- // then intercepted above to cycle staging slots while any slot exists.
- ['canvas.prevEntity', t('widgets.canvas.commands.previousEntity'), ['alt+[']],
- ['canvas.nextEntity', t('widgets.canvas.commands.nextEntity'), ['alt+]']],
- ['canvas.quickSwitch', t('widgets.canvas.commands.quickSwitch'), ['q']],
- ['canvas.deleteSelected', t('widgets.canvas.commands.deleteSelected'), ['delete', 'backspace']],
- ['canvas.resetSelected', t('widgets.canvas.commands.resetSelected'), ['shift+c']],
- ['canvas.undo', t('widgets.canvas.commands.undo'), ['mod+z']],
- ['canvas.redo', t('widgets.canvas.commands.redo'), ['mod+shift+z', 'mod+y']],
- // Exclude editable targets so tool letters and size brackets do not intercept typing.
- ['canvas.tool.view', t('widgets.canvas.commands.selectViewTool'), ['h']],
- ['canvas.tool.move', t('widgets.canvas.commands.selectMoveTool'), ['v']],
- ['canvas.transformSelected', t('widgets.canvas.commands.selectTransformTool'), ['mod+t']],
- ['canvas.tool.bbox', t('widgets.canvas.commands.selectBboxTool'), []],
- ['canvas.tool.brush', t('widgets.canvas.commands.selectBrushTool'), ['b']],
- ['canvas.tool.eraser', t('widgets.canvas.commands.selectEraserTool'), ['e']],
- ['canvas.tool.lasso', t('widgets.canvas.commands.selectLassoTool'), ['l']],
- ['canvas.tool.marquee', t('widgets.canvas.commands.selectMarqueeTool'), ['u']],
- ['canvas.toggleNonRasterLayers', t('widgets.canvas.commands.toggleNonRasterLayers'), ['shift+h']],
- ['canvas.copySelection', t('widgets.canvas.commands.copySelection'), ['mod+c']],
- ['canvas.cutSelection', t('widgets.canvas.commands.cutSelection'), ['mod+x']],
- ['canvas.pasteImage', t('widgets.canvas.commands.pasteImage'), ['mod+v']],
- ['canvas.tool.shape', t('widgets.canvas.commands.selectShapeTool'), ['r']],
- ['canvas.tool.text', t('widgets.canvas.commands.selectTextTool'), ['t']],
- ['canvas.tool.gradient', t('widgets.canvas.commands.selectGradientTool'), ['g']],
- // Selection: select all / deselect / invert (engine-owned transient selection).
- ['canvas.selectAll', t('widgets.canvas.commands.selectAll'), ['mod+a']],
- ['canvas.deselect', t('widgets.canvas.commands.deselect'), ['mod+d']],
- ['canvas.invertSelection', t('widgets.canvas.commands.invertSelection'), ['mod+shift+i']],
- ['canvas.brushSizeDown', t('widgets.canvas.commands.decreaseBrushSize'), ['[']],
- ['canvas.brushSizeUp', t('widgets.canvas.commands.increaseBrushSize'), [']']],
- // The active color pair: X swaps, D resets to black/white.
- ['canvas.toggleFillColor', t('widgets.canvas.commands.swapColors'), ['x']],
- ['canvas.setFillColorsToDefault', t('widgets.canvas.commands.resetColors'), ['d']],
- // Move the selected layer: arrows nudge 1px, shift+arrows 10px.
- ['canvas.nudgeLeft', t('widgets.canvas.commands.nudgeLeft'), ['arrowleft']],
- ['canvas.nudgeRight', t('widgets.canvas.commands.nudgeRight'), ['arrowright']],
- ['canvas.nudgeUp', t('widgets.canvas.commands.nudgeUp'), ['arrowup']],
- ['canvas.nudgeDown', t('widgets.canvas.commands.nudgeDown'), ['arrowdown']],
- ['canvas.nudgeLeftLarge', t('widgets.canvas.commands.nudgeLeftLarge'), ['shift+arrowleft']],
- ['canvas.nudgeRightLarge', t('widgets.canvas.commands.nudgeRightLarge'), ['shift+arrowright']],
- ['canvas.nudgeUpLarge', t('widgets.canvas.commands.nudgeUpLarge'), ['shift+arrowup']],
- ['canvas.nudgeDownLarge', t('widgets.canvas.commands.nudgeDownLarge'), ['shift+arrowdown']],
- // Layer management.
- ['canvas.duplicateLayer', t('widgets.canvas.commands.duplicateLayer'), ['mod+j']],
- ['canvas.groupLayers', t('widgets.canvas.commands.groupLayers'), ['mod+g']],
- ['canvas.ungroupLayers', t('widgets.canvas.commands.ungroupLayers'), ['mod+shift+g']],
- ['canvas.mergeDown', t('widgets.canvas.commands.mergeDown'), ['mod+e']],
- ['canvas.layerForward', t('widgets.canvas.commands.layerForward'), ['mod+]']],
- ['canvas.layerBackward', t('widgets.canvas.commands.layerBackward'), ['mod+[']],
- ['canvas.layerToFront', t('widgets.canvas.commands.layerToFront'), ['mod+shift+]']],
- ['canvas.layerToBack', t('widgets.canvas.commands.layerToBack'), ['mod+shift+[']],
- ] as const;
- const disposers = hotkeys.flatMap(([id, title, defaultKeys]) => [
- runtime.commands.register({ handler: () => executeCanvasHotkey(id), id, title }),
- runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }),
- ]);
+ // Exclude editable targets so tool letters and size brackets do not intercept typing.
+ const disposers = [...CANVAS_COMPANION_HOTKEYS, ...CANVAS_ONLY_HOTKEYS].flatMap(([id, titleKey, defaultKeys]) => {
+ const title = t(titleKey);
+ return [
+ runtime.commands.register({ handler: () => executeCanvasHotkey(id), id, title }),
+ runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }),
+ ];
+ });
return () => {
disposers.forEach((dispose) => dispose());
@@ -611,16 +570,21 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => {
() => ,
[createFromBbox, isCompositeMenuDisabled]
);
+ const isCropAllLocked = isInteractionLocked || interactionCapabilities.isDocumentEditingLocked;
const compositeSubmenus = useMemo(
() => (
<>
{saveToGallerySubmenu}
{createFromBboxSubmenu}
+ {engine ? : null}
>
),
- [createFromBboxSubmenu, saveToGallerySubmenu]
+ [createFromBboxSubmenu, engine, isCropAllLocked, saveToGallerySubmenu]
+ );
+ const canvasSurface = useMemo(
+ () => (engine ? : null),
+ [engine, instance.id]
);
- const canvasSurface = useMemo(() => (engine ? : null), [engine]);
return (
{
{
projectId="project"
selectedModel={SD1_MODEL}
onCommit={vi.fn()}
+ onResetSize={vi.fn()}
/>
);
};
@@ -148,7 +149,7 @@ const badgeText = () =>
)?.textContent;
const statusText = () =>
[...(host?.querySelectorAll('p') ?? [])].find((element) => element.textContent?.includes('MP'))?.textContent;
-const optimalSizeButton = () => page.getByRole('button', { name: 'Set optimal size' });
+const resetSizeButton = () => page.getByRole('button', { name: 'Reset size' });
const renderAt = () => page.getByRole('combobox', { name: 'Render at' });
/** The dashed ghost in the size preview, in preview pixels. */
@@ -336,26 +337,14 @@ describe('Size section in canvas mode', () => {
await expect.element(width).toHaveValue('1608');
});
- it('offers the optimal frame size only while the frame renders at its own size', async () => {
- // An undersized frame: Auto grows it and Custom sets its own, so resizing the frame adds nothing.
+ it('offers the Reset size menu in every render mode, without a footer optimal-size button', async () => {
await renderSizeSection(createProject({ frame: { height: 384, width: 384 }, model: SD1_MODEL }));
- await expect.element(optimalSizeButton()).not.toBeInTheDocument();
+ await expect.element(resetSizeButton()).toBeInTheDocument();
await chooseRenderAt('Custom');
- await expect.element(optimalSizeButton()).not.toBeInTheDocument();
+ await expect.element(resetSizeButton()).toBeInTheDocument();
await chooseRenderAt('Frame size');
- await expect.element(optimalSizeButton()).toBeInTheDocument();
- await unmount();
-
- // An oversized frame: Auto never shrinks it.
- await renderSizeSection(createProject({ frame: { height: 768, width: 768 }, model: SD1_MODEL }));
- await expect.element(optimalSizeButton()).toBeInTheDocument();
- await unmount();
-
- // A frame already at the recommended size has nothing to offer in any mode.
- await renderSizeSection(
- createProject({ canvas: { scaleMethod: 'none' }, frame: { height: 512, width: 512 }, model: SD1_MODEL })
- );
- await expect.element(optimalSizeButton()).not.toBeInTheDocument();
+ await expect.element(resetSizeButton()).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Set optimal size' })).not.toBeInTheDocument();
});
it('leaves the Size section as it is when the canvas is not the invocation source', async () => {
@@ -371,7 +360,7 @@ describe('Size section in canvas mode', () => {
await expect.element(renderAt()).not.toBeInTheDocument();
expect(badgeText()).toBe('384x384');
expect(statusText()).toMatch(/^0\.15 MP/);
- await expect.element(optimalSizeButton()).toBeInTheDocument();
+ await expect.element(resetSizeButton()).toBeInTheDocument();
// The ghost is the recommended 512x512, not the canvas's custom render size.
expect(ghostSize()).toEqual([88, 88]);
});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx
index 75a7ebc7c01..617ff12ee74 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx
@@ -357,6 +357,52 @@ describe('tool strip family slots', () => {
}
});
+ /** Hover the slot fresh, so its tooltip opens with the current title. */
+ const hoverSlot = async (slot: HTMLElement) => {
+ await act(() => userEvent.unhover(slot));
+ await act(() => userEvent.hover(slot));
+ };
+ const tooltipText = () => document.querySelector('[role="tooltip"]')?.textContent ?? null;
+ const MARQUEE_HELP =
+ "Drag a rectangle or ellipse to select pixels on the active layer. Fill, erase, or move them to a new layer. Doesn't change the generation frame.";
+ const LASSO_HELP =
+ "Draw a freehand or polygon outline to select pixels on the active layer. Fill, erase, or move them to a new layer. Doesn't change the generation frame.";
+
+ it('titles the selection slot with the current tool’s effective key and its help line, remaps included', async () => {
+ accountLifecycle.activate('tool-strip-test');
+ await patchWorkbenchPreferences({ customHotkeys: { 'canvas.tool.lasso': ['k'] } });
+ const extensions = createExtensionRegistry();
+ const source = { instanceId: 'canvas', projectId: 'project-1', region: 'center', typeId: 'canvas' } as const;
+ for (const [id, key] of [
+ ['canvas.tool.marquee', 'u'],
+ ['canvas.tool.lasso', 'l'],
+ ] as const) {
+ extensions.hotkeys.register({ commandId: id, defaultKeys: [key], id, source, title: id });
+ }
+ workbench.extensions = extensions;
+
+ try {
+ await renderStrip();
+ const marquee = (await button('Marquee select').element()) as HTMLElement;
+ await hoverSlot(marquee);
+ await expect.poll(tooltipText).toBe(`Marquee select (U)${MARQUEE_HELP}`);
+
+ await openByContextMenu(marquee);
+ await act(() => userEvent.click(page.getByRole('menuitemradio', { name: /^Lasso select/ })));
+ // The lasso key was remapped from L to K; the title follows the effective binding.
+ await hoverSlot((await button('Lasso select').element()) as HTMLElement);
+ await expect.poll(tooltipText).toBe(`Lasso select (K)${LASSO_HELP}`);
+ } finally {
+ await patchWorkbenchPreferences({ customHotkeys: {} });
+ }
+ });
+
+ it('titles a slot without a binding by its label alone', async () => {
+ await renderStrip();
+ await hoverSlot((await button('Marquee select').element()) as HTMLElement);
+ await expect.poll(tooltipText).toBe(`Marquee select${MARQUEE_HELP}`);
+ });
+
it('remembers the last-used selection tool across tool switches', async () => {
const fake = await renderStrip();
await act(() => userEvent.click(button('Marquee select')));
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx
index 64a1729921a..69f4b1b44dc 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx
@@ -16,14 +16,14 @@ import {
BrushIcon,
CircleIcon,
EraserIcon,
- FrameIcon,
HandIcon,
LassoIcon,
MousePointer2Icon,
PenLineIcon,
PentagonIcon,
Rotate3dIcon,
- SquareDashedIcon,
+ ScanIcon,
+ SquareDashedMousePointerIcon,
SquareIcon,
StarIcon,
TriangleIcon,
@@ -154,12 +154,14 @@ const SelectFamilyButton = ({
const items: ToolFlyoutItem[] = useMemo(
() => [
{
- icon: SquareDashedIcon,
+ description: t('widgets.canvas.tools.marqueeHelp'),
+ icon: SquareDashedMousePointerIcon,
id: 'marquee',
label: t('widgets.canvas.tools.marquee'),
hotkeys: toolHotkeys(hotkeyDefinitions, customHotkeys, 'canvas.tool.marquee'),
},
{
+ description: t('widgets.canvas.tools.lassoHelp'),
icon: LassoIcon,
id: 'lasso',
label: t('widgets.canvas.tools.lasso'),
@@ -180,7 +182,7 @@ const SelectFamilyButton = ({
{
@@ -66,6 +69,7 @@ export const ToolFamilyButton = ({
}) => {
// The menu anchors to, and returns focus to, the element carrying the trigger id: the slot, through its tooltip.
// The slot is not a `Menu.Trigger`, whose click would open the menu instead of selecting the current subtool.
+ const { t } = useTranslation();
const ids = useTooltipTriggerIds();
const menuMachine = useMenu({ ids, positioning: MENU_POSITIONING });
const menu = menuMachine.api;
@@ -229,12 +233,34 @@ export const ToolFamilyButton = ({
[disabled, items, menu, onSelectSubtool]
);
+ // The title names the slot with the current subtool's first effective binding; its help line follows.
+ const current = items.find((item) => item.id === currentId);
+ const currentHotkey = current?.hotkeys?.[0];
+ const description = current?.description;
+ const title = currentHotkey
+ ? t('widgets.canvas.tools.withHotkey', { hotkey: formatHotkeyLabel(currentHotkey), label })
+ : label;
+ const tooltip = useMemo(
+ () =>
+ description ? (
+
+ {title}
+
+ {description}
+
+
+ ) : (
+ title
+ ),
+ [description, title]
+ );
+
return (
{/* Forcing `open={false}` (not `disabled`) suppresses the tooltip while
the menu is open: the disabled path unwraps the trigger and remounts
the button, dropping pointer capture mid-hold. */}
-
+
} },
+});
const baseOptions = {
clientX: 240,
@@ -63,3 +88,115 @@ describe('resolveCanvasContextMenu', () => {
expect(resolveCanvasContextMenuBranch(resolution.target, false)).toBe('global');
});
});
+
+describe('getCropAllUnavailableReasonKey', () => {
+ const documentWith = (nodes: CanvasNodeContract[]): CanvasDocumentContractV3 => ({
+ ...createEmptyCanvasDocument(),
+ stacks: stacksFrom(nodes),
+ });
+
+ it('finds a croppable leaf in any stack, hidden or disabled ones included', () => {
+ expect(
+ getCropAllUnavailableReasonKey(documentWith([layerContract('mask', 'inpaint_mask', { isEnabled: false })]))
+ ).toBeNull();
+ expect(
+ getCropAllUnavailableReasonKey(documentWith([groupContract('G', [layerContract('region', 'regional_guidance')])]))
+ ).toBeNull();
+ });
+
+ it('says why there is nothing to crop: no layers, or every leaf locked by itself or a group above it', () => {
+ expect(getCropAllUnavailableReasonKey(documentWith([]))).toBe('widgets.canvas.contextMenu.cropAllNoLayers');
+ expect(
+ getCropAllUnavailableReasonKey(
+ documentWith([
+ layerContract('locked', 'raster', { isLocked: true }),
+ groupContract('G', [layerContract('inside', 'control')], { isLocked: true }),
+ ])
+ )
+ ).toBe('widgets.canvas.contextMenu.cropAllAllLocked');
+ });
+});
+
+describe('cropAllLayersToFrame', () => {
+ type CropResult = Awaited[0]['cropAllLayersToBbox']>>;
+ const notices = async (result: CropResult | Error) => {
+ const notify = { error: vi.fn(), info: vi.fn(), success: vi.fn() };
+ const cropAllLayersToBbox = () => (result instanceof Error ? Promise.reject(result) : Promise.resolve(result));
+ await cropAllLayersToFrame({ cropAllLayersToBbox }, notify, i18n.t);
+ return {
+ error: notify.error.mock.calls,
+ info: notify.info.mock.calls,
+ success: notify.success.mock.calls,
+ };
+ };
+
+ const unreadable = (layerName: string) => ({
+ layerId: layerName.toLowerCase(),
+ layerName,
+ status: 'unreadable' as const,
+ });
+
+ it.each([
+ [{ cropped: 1, skippedLocked: 0, skippedUnreadable: [] }, ['Cropped 1 layer to the frame', undefined]],
+ [
+ { cropped: 4, skippedLocked: 1, skippedUnreadable: [] },
+ ['Cropped 4 layers to the frame', '1 locked layer skipped.'],
+ ],
+ [
+ { cropped: 2, skippedLocked: 3, skippedUnreadable: [] },
+ ['Cropped 2 layers to the frame', '3 locked layers skipped.'],
+ ],
+ [
+ { cropped: 2, skippedLocked: 1, skippedUnreadable: [unreadable('Sky'), unreadable('Hills')] },
+ [
+ 'Cropped 2 layers to the frame',
+ '1 locked layer skipped. “Sky” and 1 other layer could not be read and were left as they are.',
+ ],
+ ],
+ [
+ { cropped: 1, skippedLocked: 0, skippedUnreadable: [unreadable('Sky'), unreadable('Hills'), unreadable('Sea')] },
+ ['Cropped 1 layer to the frame', '“Sky” and 2 other layers could not be read and were left as they are.'],
+ ],
+ ])('reports %o as cropped, with the layers it left as they were', async (counts, expected) => {
+ expect(await notices({ status: 'cropped', ...counts })).toEqual({ error: [], info: [], success: [expected] });
+ });
+
+ it('says nothing needed cropping, and that only unlocked layers were checked when some were skipped', async () => {
+ expect((await notices({ skippedLocked: 0, skippedUnreadable: [], status: 'nothing' })).info).toEqual([
+ ['Every layer already fits the frame', undefined],
+ ]);
+ expect((await notices({ skippedLocked: 2, skippedUnreadable: [], status: 'nothing' })).info).toEqual([
+ ['Every unlocked layer already fits the frame', '2 locked layers skipped.'],
+ ]);
+ });
+
+ it('reports a crop that left only unreadable layers uncropped as a failure, never as still loading', async () => {
+ expect(await notices({ skippedLocked: 0, skippedUnreadable: [unreadable('Sky')], status: 'nothing' })).toEqual({
+ error: [['No layer was cropped', '“Sky” could not be read and was left as it is.']],
+ info: [],
+ success: [],
+ });
+ });
+
+ it('names the layer whose adjustments prevented the crop', async () => {
+ const result = await notices({
+ ownerId: 'haze',
+ ownerName: 'Haze',
+ reason: 'too-large',
+ status: 'adjustment-refused',
+ });
+ expect(result.error).toHaveLength(1);
+ expect(result.error[0]?.[1]).toContain('Haze');
+ expect(result.info).toEqual([]);
+ expect(result.success).toEqual([]);
+ });
+
+ it('explains refusals and a rejected crop with the shared layer notice', async () => {
+ expect((await notices({ status: 'over-budget' })).error).toEqual([
+ [i18n.t('widgets.layers.actions.actionFailed'), i18n.t('widgets.layers.actions.overBudget')],
+ ]);
+ expect((await notices(new Error('rejected'))).error).toEqual([
+ [i18n.t('widgets.layers.actions.actionFailed'), i18n.t('widgets.layers.actions.operationFailed')],
+ ]);
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts
index 316f9c0661c..97ce90ff6d5 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts
@@ -1,3 +1,11 @@
+import type { CanvasDocumentContractV3, CanvasEngineLayerCapability } from '@workbench/canvas-engine/api';
+import type { Notify } from '@workbench/useNotify';
+import type { TFunction } from 'i18next';
+
+import { getDocumentIndex } from '@workbench/canvas-engine/api';
+
+import { reportLayerOperation } from './useStructuralCommit';
+
export interface CanvasContextMenuTarget {
x: number;
y: number;
@@ -50,3 +58,76 @@ export const resolveCanvasContextMenu = ({
target: { layerId, x: clientX, y: clientY },
};
};
+
+/**
+ * Why Crop all layers to frame cannot run on `document`, or null when it has a leaf to act on: one not locked by
+ * itself or a group above it.
+ */
+export const getCropAllUnavailableReasonKey = (document: CanvasDocumentContractV3): string | null => {
+ const { byId, leaves } = getDocumentIndex(document);
+ if (leaves.length === 0) {
+ return 'widgets.canvas.contextMenu.cropAllNoLayers';
+ }
+ return leaves.some((leaf) => !leaf.isLocked && !byId.get(leaf.id)?.ancestorsLocked)
+ ? null
+ : 'widgets.canvas.contextMenu.cropAllAllLocked';
+};
+
+/**
+ * Crops every unlocked layer to the frame as one undo step and says what happened: how many layers it cropped, how
+ * many locked ones it skipped, which layer could not be read (named, with a count of any others), or that nothing
+ * needed cropping. The canvas menu and the command share it.
+ */
+export const cropAllLayersToFrame = async (
+ layers: Pick,
+ notify: Pick,
+ t: TFunction
+): Promise => {
+ let result: Awaited>;
+ try {
+ result = await layers.cropAllLayersToBbox();
+ } catch {
+ // The engine leaves every layer as it was when the transaction is rejected.
+ result = { status: 'failed' };
+ }
+ switch (result.status) {
+ case 'cropped':
+ case 'nothing': {
+ const { skippedLocked, skippedUnreadable } = result;
+ const [firstUnreadable] = skippedUnreadable;
+ const skipped =
+ [
+ skippedLocked > 0 ? t('widgets.canvas.contextMenu.croppedAllLayersSkipped', { count: skippedLocked }) : '',
+ !firstUnreadable
+ ? ''
+ : skippedUnreadable.length === 1
+ ? t('widgets.canvas.contextMenu.cropAllUnreadable', { name: firstUnreadable.layerName })
+ : t('widgets.canvas.contextMenu.cropAllUnreadableWithOthers', {
+ count: skippedUnreadable.length - 1,
+ name: firstUnreadable.layerName,
+ }),
+ ]
+ .filter(Boolean)
+ .join(' ') || undefined;
+ if (result.status === 'cropped') {
+ notify.success(t('widgets.canvas.contextMenu.croppedAllLayers', { count: result.cropped }), skipped);
+ } else if (firstUnreadable) {
+ // Those layers needed cropping and could not be read: nothing the user asked for happened.
+ notify.error(t('widgets.canvas.contextMenu.cropAllNoneCropped'), skipped);
+ } else {
+ notify.info(
+ t(
+ skipped ? 'widgets.canvas.contextMenu.cropAllNothingUnlocked' : 'widgets.canvas.contextMenu.cropAllNothing'
+ ),
+ skipped
+ );
+ }
+ return;
+ }
+ case 'adjustment-refused':
+ reportLayerOperation(result, notify.error, t);
+ return;
+ default:
+ reportLayerOperation(result.status, notify.error, t);
+ }
+};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts
index f71fcf43f06..9163ebdfb18 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts
@@ -179,6 +179,18 @@ describe('executeCanvasHeaderCommand', () => {
expect(saveToGallery).toHaveBeenCalledWith(region);
});
+ it.each([
+ ['canvas.setZoomTo100Percent', 1],
+ ['canvas.setZoomTo200Percent', 2],
+ ['canvas.setZoomTo400Percent', 4],
+ ['canvas.setZoomTo800Percent', 8],
+ ] as const)('%s zooms to %d× about the viewport centre', (commandId, scale) => {
+ const { engine, zoomAtPoint } = createEngine({ height: 600, width: 800 });
+ executeCanvasHeaderCommand(commandId, contextOf({ engine }));
+ expect(zoomAtPoint).toHaveBeenCalledExactlyOnceWith(scale, { x: 400, y: 300 });
+ expect(engine.layers.commitStructural).not.toHaveBeenCalled();
+ });
+
it('ignores an unknown command id', () => {
const openNewCanvas = vi.fn();
const { engine } = createEngine();
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts
index db828429777..83e2186eb07 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts
@@ -80,9 +80,36 @@ export const confirmNewCanvas = (ctx: NewCanvasContext): void => {
ctx.engine.document.replaceDocument(createNewCanvasState(ctx.document.width, ctx.document.height).document);
};
+/** The header's commands as `[id, titleKey, defaultKeys]`; the header registers them while the Canvas is mounted. */
+export const CANVAS_HEADER_HOTKEYS = [
+ ['canvas.fitBboxToLayers', 'widgets.canvas.controls.fitBboxToLayers', ['shift+n']],
+ ['canvas.fitBboxToMasks', 'widgets.canvas.controls.fitBboxToMasks', ['shift+b']],
+ // The gallery saves have no default keys; they are assignable through the hotkeys settings.
+ ['canvas.saveToGallery', 'widgets.canvas.contextMenu.saveCanvasToGallery', []],
+ ['canvas.saveBboxToGallery', 'widgets.canvas.contextMenu.saveBboxToGallery', []],
+ // Clear canvas keeps its saved-binding id, `newSession`.
+ ['canvas.newSession', 'widgets.canvas.controls.clearCanvas', ['mod+alt+n']],
+ // Browsers reserve Mod+1…8 for tab switching; these act only where the browser delivers the chord.
+ ['canvas.setZoomTo100Percent', 'widgets.canvas.commands.zoomTo100', ['mod+1']],
+ ['canvas.setZoomTo200Percent', 'widgets.canvas.commands.zoomTo200', ['mod+2']],
+ ['canvas.setZoomTo400Percent', 'widgets.canvas.commands.zoomTo400', ['mod+3']],
+ ['canvas.setZoomTo800Percent', 'widgets.canvas.commands.zoomTo800', ['mod+4']],
+] as const satisfies readonly (readonly [id: string, titleKey: string, defaultKeys: readonly string[]])[];
+
+/** The zoom preset commands and the scale each sets about the viewport centre. */
+const ZOOM_PRESET_COMMANDS: Readonly> = {
+ 'canvas.setZoomTo100Percent': 1,
+ 'canvas.setZoomTo200Percent': 2,
+ 'canvas.setZoomTo400Percent': 4,
+ 'canvas.setZoomTo800Percent': 8,
+};
+
/** Route new-session commands through the button's confirmation dialog. */
export const executeCanvasHeaderCommand = (commandId: string, ctx: CanvasHeaderCommandContext): void => {
- if (commandId === 'canvas.fitBboxToLayers') {
+ const zoomPreset = ZOOM_PRESET_COMMANDS[commandId];
+ if (zoomPreset !== undefined) {
+ zoomAtViewportCentre(ctx.engine, zoomPreset);
+ } else if (commandId === 'canvas.fitBboxToLayers') {
applyFitBbox(ctx, ctx.fitLayersRect, true);
} else if (commandId === 'canvas.fitBboxToMasks') {
applyFitBbox(ctx, ctx.fitMasksRect, false);
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 4a35ce97902..df44d62856d 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts
@@ -1,4 +1,5 @@
import type {
+ BeginTransformResult,
CanvasDocumentContractV3,
CanvasEngine,
CanvasInteractionState,
@@ -8,13 +9,16 @@ import type {
NewRasterLayerResult,
} from '@workbench/canvas-engine/api';
import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations';
+import type { TFunction } from 'i18next';
import { createControlLayer, createDocumentModel } from '@workbench/canvas-engine/api';
import { groupContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub';
import { stackTopAnchor } from '@workbench/canvas-engine/document/insertionAnchors.testStub';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
-import { executeCanvasHotkeyCommand, type CanvasHotkeyContext } from './canvasHotkeyCommands';
+import { CANVAS_HEADER_HOTKEYS } from './canvasHeaderCommands';
+import { CANVAS_ONLY_HOTKEYS, executeCanvasHotkeyCommand, type CanvasHotkeyContext } from './canvasHotkeyCommands';
+import { CANVAS_COMPANION_HOTKEYS, executeCanvasToolCommand, reportTransformRefusal } from './canvasToolCommands';
const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract =>
({
@@ -77,6 +81,7 @@ const createEngine = (interaction: Partial = {}) => {
status: 'duplicated',
})
),
+ beginTransform: vi.fn((): BeginTransformResult => 'started'),
mergeLayerDown: vi.fn((): Promise => Promise.resolve('merged')),
nudgeSelectedLayer: vi.fn(() => ({ status: 'committed' as const })),
},
@@ -98,8 +103,10 @@ let pasteFromClipboard: Mock<() => void>;
const contextOf = (overrides: Partial = {}): CanvasHotkeyContext => ({
copySelection,
+ cropAllLayers: vi.fn(),
dispatch,
reportLayerOperation: vi.fn(),
+ reportTransformRefusal: vi.fn(),
reportPreparedCommit: () => undefined,
reportStructuralCommit: () => undefined,
document: documentOf([rasterLayer('a'), rasterLayer('b')], 'a'),
@@ -451,6 +458,18 @@ describe('clipboard', () => {
});
});
+describe('crop all layers to frame', () => {
+ it('runs the canvas menu action, but not while interaction is locked', () => {
+ const free = contextOf();
+ executeCanvasHotkeyCommand('canvas.cropAllLayersToFrame', free);
+ expect(free.cropAllLayers).toHaveBeenCalledOnce();
+
+ const locked = contextOf({ isInteractionLocked: true });
+ executeCanvasHotkeyCommand('canvas.cropAllLayersToFrame', locked);
+ expect(locked.cropAllLayers).not.toHaveBeenCalled();
+ });
+});
+
describe('tool selection', () => {
it.each([
['canvas.tool.view', 'view'],
@@ -461,12 +480,54 @@ describe('tool selection', () => {
['canvas.tool.shape', 'shape'],
['canvas.tool.text', 'text'],
['canvas.tool.gradient', 'gradient'],
- ['canvas.transformSelected', 'transform'],
])('%s selects the %s tool', (commandId, tool) => {
const engine = run(commandId);
expect(engine.tools.setTool).toHaveBeenCalledWith(tool);
});
+ it('transform opens a session on the selected leaf at once, without a separate tool switch', () => {
+ const engine = run('canvas.transformSelected');
+ expect(engine.layers.beginTransform).toHaveBeenCalledExactlyOnceWith('a');
+ expect(engine.tools.setTool).not.toHaveBeenCalled();
+ });
+
+ it('transform only picks the tool with no selection or a group selected', () => {
+ const none = run('canvas.transformSelected', { document: documentOf([rasterLayer('a')], null) });
+ expect(none.layers.beginTransform).not.toHaveBeenCalled();
+ expect(none.tools.setTool).toHaveBeenCalledWith('transform');
+ const group = run('canvas.transformSelected', {
+ document: { ...documentOf([], 'g'), stacks: stacksFrom([groupContract('g', [rasterLayer('a')])]) },
+ });
+ expect(group.layers.beginTransform).not.toHaveBeenCalled();
+ expect(group.tools.setTool).toHaveBeenCalledWith('transform');
+ });
+
+ it('transform explains a refusal and stays quiet mid-gesture', () => {
+ const report = (result: BeginTransformResult) => {
+ const engine = createEngine();
+ engine.layers.beginTransform.mockReturnValue(result);
+ const reportTransformRefusal = vi.fn();
+ executeCanvasHotkeyCommand('canvas.transformSelected', contextOf({ engine, reportTransformRefusal }));
+ expect(engine.tools.setTool).not.toHaveBeenCalled();
+ return reportTransformRefusal.mock.calls;
+ };
+ expect(report('locked')).toEqual([['locked']]);
+ expect(report('ineligible')).toEqual([['ineligible']]);
+ expect(report('busy')).toEqual([]);
+ });
+
+ it("gives an ineligible layer Transform's own reason and other refusals the shared layer notice", () => {
+ const explain = (refusal: Exclude) => {
+ const reportError = vi.fn();
+ reportTransformRefusal(refusal, reportError, ((key: string) => key) as unknown as TFunction);
+ return reportError.mock.calls;
+ };
+ expect(explain('ineligible')).toEqual([
+ ['widgets.layers.actions.actionFailed', 'widgets.layers.actions.transformIneligible'],
+ ]);
+ expect(explain('locked')).toEqual([['widgets.layers.actions.actionFailed', 'widgets.layers.actions.locked']]);
+ });
+
it('lasso selects the tool when not already active', () => {
const engine = run('canvas.tool.lasso');
expect(engine.tools.setTool).toHaveBeenCalledWith('lasso');
@@ -727,6 +788,24 @@ describe('group selection', () => {
expect.objectContaining({ forward: expect.objectContaining({ removeIds: ['g'] }) })
);
});
+
+ it('ungroup on leaves of one group moves them out above it, and does nothing for a top-level leaf', () => {
+ const document = nodeDocument([groupContract('g', [rasterLayer('a'), rasterLayer('b')]), rasterLayer('c')], 'a');
+ const lifted = run('canvas.ungroupLayers', { document, selectedLayerIds: ['b', 'a'] });
+ expect(lifted.layers.commitPrepared).toHaveBeenCalledExactlyOnceWith(
+ 'widgets.layers.actions.removeFromGroup',
+ expect.objectContaining({
+ forward: expect.objectContaining({
+ move: [{ anchor: expect.objectContaining({ beforeId: 'g', parentPath: [] }), ids: ['a', 'b'] }],
+ }),
+ })
+ );
+ const topLevel = run('canvas.ungroupLayers', {
+ document: { ...document, selectedLayerId: 'c' },
+ selectedLayerIds: ['c'],
+ });
+ expect(topLevel.layers.commitPrepared).not.toHaveBeenCalled();
+ });
});
describe('active color pair commands', () => {
@@ -744,3 +823,43 @@ describe('active color pair commands', () => {
}
});
});
+
+describe('registration tables', () => {
+ // Everything the mounted Canvas registers: its own keys, the companion keys and the header's commands. The
+ // companion widget registers a subset (history plus companion keys), so it is covered too.
+ const allEntries = [...CANVAS_COMPANION_HOTKEYS, ...CANVAS_ONLY_HOTKEYS, ...CANVAS_HEADER_HOTKEYS];
+
+ it('gives Transform Shift+T as its only default', () => {
+ expect(CANVAS_COMPANION_HOTKEYS.find(([id]) => id === 'canvas.transformSelected')?.[2]).toEqual(['shift+t']);
+ expect(allEntries.flatMap(([, , keys]) => keys)).not.toContain('mod+t');
+ });
+
+ it('never gives two entries the same id or default chord', () => {
+ const ids = allEntries.map(([id]) => id);
+ const chords = allEntries.flatMap(([, , keys]) => keys);
+ expect(new Set(ids).size).toBe(ids.length);
+ expect(chords.filter((chord, index) => chords.indexOf(chord) !== index)).toEqual([]);
+ expect(chords).toEqual(expect.arrayContaining(['mod+1', 'mod+2', 'mod+3', 'mod+4', 'mod+z', 'shift+t']));
+ });
+
+ it('shares every companion key with the companion runner and nothing else', () => {
+ const engine = createEngine();
+ const ctx = {
+ engine,
+ isInteractionLocked: false,
+ reportTransformRefusal: vi.fn(),
+ resetActiveColors: vi.fn(),
+ selectedLayerId: null,
+ swapActiveColors: vi.fn(),
+ };
+ for (const [id] of CANVAS_COMPANION_HOTKEYS) {
+ executeCanvasToolCommand(id, ctx);
+ }
+ // Every companion key reaches the engine or the color pair; bbox has no default key but still runs.
+ expect(engine.tools.setTool.mock.calls.length + engine.tools.stepBrushSize.mock.calls.length).toBe(
+ CANVAS_COMPANION_HOTKEYS.length - 2
+ );
+ expect(ctx.swapActiveColors).toHaveBeenCalledOnce();
+ expect(ctx.resetActiveColors).toHaveBeenCalledOnce();
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts
index 25d676abf22..2ad7c516f53 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts
@@ -1,5 +1,6 @@
import type {
AdjustmentRefusal,
+ LayerUnreadable,
CanvasDocumentContractV3,
CanvasEngine,
LayerStackMoveKind,
@@ -15,6 +16,13 @@ import {
isOverlayStack,
} from '@workbench/canvas-engine/api';
import { publishLayerPanelSelection } from '@workbench/layerPanelState';
+import {
+ CANVAS_HISTORY_HOTKEYS,
+ executeCanvasToolCommand,
+ isCanvasToolCommand,
+ type CanvasToolCommandContext,
+ type HotkeyTableEntry,
+} from '@workbench/widgets/canvas/canvasToolCommands';
import {
commitPreparedEdit,
type LayerOperationRefusal,
@@ -22,12 +30,54 @@ import {
} from '@workbench/widgets/canvas/useStructuralCommit';
import {
canGroupSelection,
+ canRemoveFromGroup,
canUngroupSelection,
groupLayers,
+ removeFromGroup,
ungroupLayers,
} from '@workbench/widgets/layers/layerGroupCommands';
import { canMergeLayerDown } from '@workbench/widgets/layers/layerOps';
+/** Keys that act only while the Canvas itself holds focus; rows and fields own them elsewhere. */
+export const CANVAS_ONLY_HOTKEYS = [
+ ...CANVAS_HISTORY_HOTKEYS,
+ // Staging keeps `alt+[` / `alt+]`; bare left/right are registered as layer nudges,
+ // then intercepted to cycle staging slots while any slot exists.
+ ['canvas.prevEntity', 'widgets.canvas.commands.previousEntity', ['alt+[']],
+ ['canvas.nextEntity', 'widgets.canvas.commands.nextEntity', ['alt+]']],
+ ['canvas.quickSwitch', 'widgets.canvas.commands.quickSwitch', ['q']],
+ ['canvas.deleteSelected', 'widgets.canvas.commands.deleteSelected', ['delete', 'backspace']],
+ ['canvas.resetSelected', 'widgets.canvas.commands.resetSelected', ['shift+c']],
+ ['canvas.toggleNonRasterLayers', 'widgets.canvas.commands.toggleNonRasterLayers', ['shift+h']],
+ ['canvas.copySelection', 'widgets.canvas.commands.copySelection', ['mod+c']],
+ ['canvas.cutSelection', 'widgets.canvas.commands.cutSelection', ['mod+x']],
+ ['canvas.pasteImage', 'widgets.canvas.commands.pasteImage', ['mod+v']],
+ // Selection: select all / deselect / invert (engine-owned transient selection).
+ ['canvas.selectAll', 'widgets.canvas.commands.selectAll', ['mod+a']],
+ ['canvas.deselect', 'widgets.canvas.commands.deselect', ['mod+d']],
+ ['canvas.invertSelection', 'widgets.canvas.commands.invertSelection', ['mod+shift+i']],
+ // Move the selected layer: arrows nudge 1px, shift+arrows 10px.
+ ['canvas.nudgeLeft', 'widgets.canvas.commands.nudgeLeft', ['arrowleft']],
+ ['canvas.nudgeRight', 'widgets.canvas.commands.nudgeRight', ['arrowright']],
+ ['canvas.nudgeUp', 'widgets.canvas.commands.nudgeUp', ['arrowup']],
+ ['canvas.nudgeDown', 'widgets.canvas.commands.nudgeDown', ['arrowdown']],
+ ['canvas.nudgeLeftLarge', 'widgets.canvas.commands.nudgeLeftLarge', ['shift+arrowleft']],
+ ['canvas.nudgeRightLarge', 'widgets.canvas.commands.nudgeRightLarge', ['shift+arrowright']],
+ ['canvas.nudgeUpLarge', 'widgets.canvas.commands.nudgeUpLarge', ['shift+arrowup']],
+ ['canvas.nudgeDownLarge', 'widgets.canvas.commands.nudgeDownLarge', ['shift+arrowdown']],
+ // Layer management.
+ ['canvas.duplicateLayer', 'widgets.canvas.commands.duplicateLayer', ['mod+j']],
+ ['canvas.groupLayers', 'widgets.canvas.commands.groupLayers', ['mod+g']],
+ ['canvas.ungroupLayers', 'widgets.canvas.commands.ungroupLayers', ['mod+shift+g']],
+ ['canvas.mergeDown', 'widgets.canvas.commands.mergeDown', ['mod+e']],
+ ['canvas.layerForward', 'widgets.canvas.commands.layerForward', ['mod+]']],
+ ['canvas.layerBackward', 'widgets.canvas.commands.layerBackward', ['mod+[']],
+ ['canvas.layerToFront', 'widgets.canvas.commands.layerToFront', ['mod+shift+]']],
+ ['canvas.layerToBack', 'widgets.canvas.commands.layerToBack', ['mod+shift+[']],
+ // No default key — assignable through the hotkeys settings; the canvas menu offers the same action.
+ ['canvas.cropAllLayersToFrame', 'widgets.canvas.contextMenu.cropAllLayersToBbox', []],
+] as const satisfies readonly HotkeyTableEntry[];
+
/** Command id → document-space nudge delta (shift variants are ×10). */
const NUDGE_DELTAS: Record = {
'canvas.nudgeDown': { dx: 0, dy: 1 },
@@ -87,26 +137,23 @@ const quickSwitchLayerId = (document: CanvasDocumentContractV3, engine: CanvasEn
};
/** Pass hotkey dependencies explicitly so dispatch can be tested without mounting React. */
-export interface CanvasHotkeyContext {
+export interface CanvasHotkeyContext extends Omit {
readonly document: CanvasDocumentContractV3;
readonly engine: CanvasEngine | null;
/** Any staging slot exists, so left/right cycle candidates instead of nudging. */
readonly hasStagingSlots: boolean;
/** A staged candidate is selected, so Delete discards it instead of touching layers. */
readonly hasSelectedStagedCandidate: boolean;
- readonly isInteractionLocked: boolean;
readonly selectedLayerIds: readonly string[];
readonly dispatch: CanvasProjectMutationDispatch;
readonly copySelection: (cut: boolean) => void;
- /** Swaps the project foreground/background pair — a preference, never a document edit. */
- readonly swapActiveColors: () => void;
- /** Resets the pair to the black-on-white default. */
- readonly resetActiveColors: () => void;
+ /** Crops every unlocked layer to the frame and reports the outcome, as the canvas menu does. */
+ readonly cropAllLayers: () => void;
readonly pasteFromClipboard: () => void;
readonly notifyLayerDuplicateFailed: () => void;
readonly reportStructuralCommit: (result: StructuralCommitResult) => void;
readonly reportPreparedCommit: (outcome: PreparedCommitOutcome) => void;
- readonly reportLayerOperation: (refusal: LayerOperationRefusal | AdjustmentRefusal) => void;
+ readonly reportLayerOperation: (refusal: LayerOperationRefusal | AdjustmentRefusal | LayerUnreadable) => void;
readonly t: (key: string) => string;
}
@@ -114,9 +161,9 @@ export interface CanvasHotkeyContext {
* Routes one canvas command id to its effect.
*
* Ordering is load-bearing and reads top-down as a precedence list: staging
- * cycling and staged-candidate discard win over layer commands, the interaction
- * lock then blocks everything except the view tool, and only after those do
- * nudge, reorder, and the per-command table apply.
+ * cycling and staged-candidate discard win over layer commands, companion keys
+ * apply their own lock rule, the interaction lock then blocks everything else,
+ * and only after those do nudge, reorder, and the per-command table apply.
*/
export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyContext): void => {
const { dispatch, document, engine, t } = ctx;
@@ -141,22 +188,12 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC
return;
}
- // The color pair is a preference, not a document edit, so X/D stay live even
- // while the surface is interaction-locked.
- if (commandId === 'canvas.toggleFillColor') {
- ctx.swapActiveColors();
- return;
- }
-
- if (commandId === 'canvas.setFillColorsToDefault') {
- ctx.resetActiveColors();
+ if (isCanvasToolCommand(commandId)) {
+ executeCanvasToolCommand(commandId, { ...ctx, selectedLayerId: selectedLayer?.id ?? null });
return;
}
if (ctx.isInteractionLocked) {
- if (commandId === 'canvas.tool.view') {
- engine?.tools.setTool('view');
- }
return;
}
@@ -212,6 +249,8 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC
ctx.copySelection(commandId === 'canvas.cutSelection');
} else if (commandId === 'canvas.pasteImage') {
ctx.pasteFromClipboard();
+ } else if (commandId === 'canvas.cropAllLayersToFrame') {
+ ctx.cropAllLayers();
} else if (commandId === 'canvas.toggleNonRasterLayers') {
// Hide overlays without disabling generation content; showing restores individually hidden nodes too.
const overlays = index.nodes.filter((entry) => isOverlayStack(entry.stack));
@@ -242,71 +281,12 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC
engine?.history.undo();
} else if (commandId === 'canvas.redo') {
engine?.history.redo();
- } else if (commandId === 'canvas.tool.view') {
- engine?.tools.setTool('view');
- } else if (commandId === 'canvas.tool.move') {
- engine?.tools.setTool('move');
- } else if (commandId === 'canvas.transformSelected') {
- // Selecting the transform tool opens a session on the selected layer (if any
- // eligible one); Apply/Cancel (enter/esc) are handled engine-side.
- engine?.tools.setTool('transform');
- } else if (commandId === 'canvas.tool.bbox') {
- engine?.tools.setTool('bbox');
- } else if (commandId === 'canvas.tool.brush') {
- engine?.tools.setTool('brush');
- } else if (commandId === 'canvas.tool.eraser') {
- engine?.tools.setTool('eraser');
- } else if (commandId === 'canvas.tool.lasso') {
- if (engine) {
- // Repeating the active tool shortcut cycles its shape.
- if (engine.interaction.get('activeTool') === 'lasso') {
- const lasso = engine.interaction.get('lassoOptions');
- engine.interaction.set('lassoOptions', {
- ...lasso,
- shape: lasso.shape === 'freehand' ? 'polygon' : 'freehand',
- });
- } else {
- engine.tools.setTool('lasso');
- }
- }
- } else if (commandId === 'canvas.tool.marquee') {
- if (engine) {
- if (engine.interaction.get('activeTool') === 'marquee') {
- const marquee = engine.interaction.get('marqueeOptions');
- engine.interaction.set('marqueeOptions', {
- ...marquee,
- kind: marquee.kind === 'rect' ? 'ellipse' : 'rect',
- });
- } else {
- engine.tools.setTool('marquee');
- }
- }
- } else if (commandId === 'canvas.tool.shape') {
- if (engine) {
- if (engine.interaction.get('activeTool') === 'shape') {
- // Repeat presses cycle the kind, like the marquee hotkey.
- const shape = engine.interaction.get('shapeOptions');
- const order = ['rect', 'ellipse', 'triangle', 'star', 'polygon', 'freehand'] as const;
- const next = order[(order.indexOf(shape.kind) + 1) % order.length]!;
- engine.interaction.set('shapeOptions', { ...shape, kind: next });
- } else {
- engine.tools.setTool('shape');
- }
- }
- } else if (commandId === 'canvas.tool.text') {
- engine?.tools.setTool('text');
- } else if (commandId === 'canvas.tool.gradient') {
- engine?.tools.setTool('gradient');
} else if (commandId === 'canvas.selectAll') {
engine?.selection.selectAll();
} else if (commandId === 'canvas.deselect') {
engine?.selection.deselect();
} else if (commandId === 'canvas.invertSelection') {
engine?.selection.invertSelection();
- } else if (commandId === 'canvas.brushSizeDown') {
- engine?.tools.stepBrushSize(-1);
- } else if (commandId === 'canvas.brushSizeUp') {
- engine?.tools.stepBrushSize(1);
} else if (commandId === 'canvas.duplicateLayer') {
// With a live pixel selection, mod+J is "layer via copy" — it lifts just
// the selected pixels. With none, it duplicates the whole layer.
@@ -334,7 +314,7 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC
ctx.notifyLayerDuplicateFailed();
return;
}
- ctx.reportLayerOperation(result.status);
+ ctx.reportLayerOperation(result.status === 'unreadable' ? result : result.status);
})
.catch(() => {
// The engine keeps rejected transactions atomic; route both rejection
@@ -349,8 +329,14 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC
);
}
} else if (commandId === 'canvas.ungroupLayers') {
- if (engine && selected && canUngroupSelection(engine.document.model(), ctx.selectedLayerIds)) {
+ // Groups dissolve; a selection of leaves of one group moves out above it instead.
+ const model = engine?.document.model() ?? null;
+ if (engine && selected && canUngroupSelection(model, ctx.selectedLayerIds)) {
ctx.reportPreparedCommit(ungroupLayers(engine, ctx.selectedLayerIds, t('widgets.canvas.commands.ungroupLayers')));
+ } else if (engine && selected && canRemoveFromGroup(model, ctx.selectedLayerIds)) {
+ ctx.reportPreparedCommit(
+ removeFromGroup(engine, ctx.selectedLayerIds, t('widgets.layers.actions.removeFromGroup'))
+ );
}
} else if (commandId === 'canvas.mergeDown') {
// Share canMergeLayerDown with the layer menu so shortcuts respect identical eligibility.
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts
new file mode 100644
index 00000000000..56af262fa7e
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts
@@ -0,0 +1,170 @@
+import type { BeginTransformResult, CanvasEngine } from '@workbench/canvas-engine/api';
+import type { TFunction } from 'i18next';
+
+import { reportLayerOperation } from '@workbench/widgets/canvas/useStructuralCommit';
+
+/** One hotkey row: its command id, title key and default keys. */
+export type HotkeyTableEntry = readonly [id: string, titleKey: string, defaultKeys: readonly string[]];
+
+/**
+ * Canvas keys that also act while a companion widget (Layers, with its Color pane) holds focus: tools, the
+ * transform session, brush size and the color pair. The Canvas and the companion both register from this table;
+ * companions import this module alone, without the Canvas's full dispatcher (`canvasHotkeyCommands.ts`).
+ */
+export const CANVAS_COMPANION_HOTKEYS = [
+ ['canvas.tool.view', 'widgets.canvas.commands.selectViewTool', ['h']],
+ ['canvas.tool.move', 'widgets.canvas.commands.selectMoveTool', ['v']],
+ ['canvas.transformSelected', 'widgets.canvas.commands.selectTransformTool', ['shift+t']],
+ ['canvas.tool.bbox', 'widgets.canvas.commands.selectBboxTool', []],
+ ['canvas.tool.brush', 'widgets.canvas.commands.selectBrushTool', ['b']],
+ ['canvas.tool.eraser', 'widgets.canvas.commands.selectEraserTool', ['e']],
+ ['canvas.tool.lasso', 'widgets.canvas.commands.selectLassoTool', ['l']],
+ ['canvas.tool.marquee', 'widgets.canvas.commands.selectMarqueeTool', ['u']],
+ ['canvas.tool.shape', 'widgets.canvas.commands.selectShapeTool', ['r']],
+ ['canvas.tool.text', 'widgets.canvas.commands.selectTextTool', ['t']],
+ ['canvas.tool.gradient', 'widgets.canvas.commands.selectGradientTool', ['g']],
+ ['canvas.brushSizeDown', 'widgets.canvas.commands.decreaseBrushSize', ['[']],
+ ['canvas.brushSizeUp', 'widgets.canvas.commands.increaseBrushSize', [']']],
+ // The active color pair: X swaps, D resets to black/white.
+ ['canvas.toggleFillColor', 'widgets.canvas.commands.swapColors', ['x']],
+ ['canvas.setFillColorsToDefault', 'widgets.canvas.commands.resetColors', ['d']],
+] as const satisfies readonly HotkeyTableEntry[];
+
+/** Canvas history, which the Canvas and its companion widgets both bind. */
+export const CANVAS_HISTORY_HOTKEYS = [
+ ['canvas.undo', 'widgets.canvas.commands.undo', ['mod+z']],
+ ['canvas.redo', 'widgets.canvas.commands.redo', ['mod+shift+z', 'mod+y']],
+] as const satisfies readonly HotkeyTableEntry[];
+
+export type CanvasToolCommandId = (typeof CANVAS_COMPANION_HOTKEYS)[number][0];
+
+const CANVAS_TOOL_COMMAND_IDS: ReadonlySet = new Set(CANVAS_COMPANION_HOTKEYS.map(([id]) => id));
+
+export const isCanvasToolCommand = (commandId: string): commandId is CanvasToolCommandId =>
+ CANVAS_TOOL_COMMAND_IDS.has(commandId);
+
+export type TransformRefusal = Exclude;
+
+/** Explains a refused `beginTransform`; a layer Transform cannot act on gets Transform's own reason. */
+export const reportTransformRefusal = (
+ refusal: TransformRefusal,
+ reportError: (title: string, message: string) => void,
+ t: TFunction
+): void => {
+ if (refusal === 'ineligible') {
+ reportError(t('widgets.layers.actions.actionFailed'), t('widgets.layers.actions.transformIneligible'));
+ } else {
+ reportLayerOperation(refusal, reportError, t);
+ }
+};
+
+/** What the companion keys need; the Canvas and the Layers widget each build it from their own state. */
+export interface CanvasToolCommandContext {
+ readonly engine: Pick | null;
+ readonly isInteractionLocked: boolean;
+ /** The selected leaf that Transform opens on; null when nothing or a group is selected. */
+ readonly selectedLayerId: string | null;
+ /** Swaps the project foreground/background pair — a preference, never a document edit. */
+ readonly swapActiveColors: () => void;
+ /** Resets the pair to the black-on-white default. */
+ readonly resetActiveColors: () => void;
+ readonly reportTransformRefusal: (refusal: TransformRefusal) => void;
+}
+
+/**
+ * Runs one companion key. The color pair is a preference, not a document edit, so X/D stay live while the surface
+ * is interaction-locked; of the rest only the view tool works then.
+ */
+export const executeCanvasToolCommand = (commandId: CanvasToolCommandId, ctx: CanvasToolCommandContext): void => {
+ const { engine } = ctx;
+ if (commandId === 'canvas.toggleFillColor') {
+ ctx.swapActiveColors();
+ return;
+ }
+ if (commandId === 'canvas.setFillColorsToDefault') {
+ ctx.resetActiveColors();
+ return;
+ }
+ if (ctx.isInteractionLocked && commandId !== 'canvas.tool.view') {
+ return;
+ }
+ if (!engine) {
+ return;
+ }
+ switch (commandId) {
+ case 'canvas.tool.view':
+ engine.tools.setTool('view');
+ return;
+ case 'canvas.tool.move':
+ engine.tools.setTool('move');
+ return;
+ case 'canvas.transformSelected': {
+ // A selected leaf opens its session at once, even with Transform already active; otherwise only the tool
+ // changes. Apply/Cancel (Enter/Escape) are handled engine-side.
+ if (ctx.selectedLayerId === null) {
+ engine.tools.setTool('transform');
+ return;
+ }
+ const result = engine.layers.beginTransform(ctx.selectedLayerId);
+ // A key pressed mid-gesture is dropped quietly; the gesture already says why.
+ if (result !== 'started' && result !== 'busy') {
+ ctx.reportTransformRefusal(result);
+ }
+ return;
+ }
+ case 'canvas.tool.bbox':
+ engine.tools.setTool('bbox');
+ return;
+ case 'canvas.tool.brush':
+ engine.tools.setTool('brush');
+ return;
+ case 'canvas.tool.eraser':
+ engine.tools.setTool('eraser');
+ return;
+ case 'canvas.tool.lasso':
+ // Repeating the active tool shortcut cycles its shape.
+ if (engine.interaction.get('activeTool') === 'lasso') {
+ const lasso = engine.interaction.get('lassoOptions');
+ engine.interaction.set('lassoOptions', {
+ ...lasso,
+ shape: lasso.shape === 'freehand' ? 'polygon' : 'freehand',
+ });
+ } else {
+ engine.tools.setTool('lasso');
+ }
+ return;
+ case 'canvas.tool.marquee':
+ if (engine.interaction.get('activeTool') === 'marquee') {
+ const marquee = engine.interaction.get('marqueeOptions');
+ engine.interaction.set('marqueeOptions', {
+ ...marquee,
+ kind: marquee.kind === 'rect' ? 'ellipse' : 'rect',
+ });
+ } else {
+ engine.tools.setTool('marquee');
+ }
+ return;
+ case 'canvas.tool.shape':
+ if (engine.interaction.get('activeTool') === 'shape') {
+ // Repeat presses cycle the kind, like the marquee hotkey.
+ const shape = engine.interaction.get('shapeOptions');
+ const order = ['rect', 'ellipse', 'triangle', 'star', 'polygon', 'freehand'] as const;
+ const next = order[(order.indexOf(shape.kind) + 1) % order.length]!;
+ engine.interaction.set('shapeOptions', { ...shape, kind: next });
+ } else {
+ engine.tools.setTool('shape');
+ }
+ return;
+ case 'canvas.tool.text':
+ engine.tools.setTool('text');
+ return;
+ case 'canvas.tool.gradient':
+ engine.tools.setTool('gradient');
+ return;
+ case 'canvas.brushSizeDown':
+ engine.tools.stepBrushSize(-1);
+ return;
+ case 'canvas.brushSizeUp':
+ engine.tools.stepBrushSize(1);
+ }
+};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx
new file mode 100644
index 00000000000..f8eea5c2800
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx
@@ -0,0 +1,363 @@
+/* oxlint-disable react-perf/jsx-no-new-function-as-prop */
+import type { CanvasLayerContract, CanvasLayerSourceContract, ColorSamplePhase } from '@workbench/canvas-engine/api';
+import type { CanvasEngine } from '@workbench/canvas-operations/createCanvasEngine';
+import type { Project } from '@workbench/projectContracts';
+import type * as workbenchContext from '@workbench/WorkbenchContext';
+import type { WorkbenchInternalStore } from '@workbench/workbenchStore';
+
+import { layerContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub';
+import { createCanvasEngine } from '@workbench/canvas-operations/createCanvasEngine';
+import { createEmptyCanvasDocument } from '@workbench/canvasMigration';
+import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort';
+import { getProjectWidgetValues } from '@workbench/widgetState';
+import { createWorkbenchStore } from '@workbench/workbenchStore';
+import { createInstance } from 'i18next';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { I18nextProvider } from 'react-i18next';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+/** The real workbench store the hooks under test read and write, standing in for the provider. */
+const harness = vi.hoisted(() => ({ store: null as WorkbenchInternalStore | null }));
+
+vi.mock('@workbench/WorkbenchContext', async (importOriginal) => {
+ const actual = await importOriginal();
+ const { useExternalStoreSelector, shallowEqual } = await import('@platform/state/selectors');
+ const store = () => harness.store!;
+ return {
+ ...actual,
+ useActiveProjectSelector: (selector: (project: Project) => T, isEqual?: (a: T, b: T) => boolean) =>
+ useExternalStoreSelector(
+ store().subscribe,
+ store().getSnapshot,
+ (snapshot) => selector(snapshot.activeProject),
+ isEqual ?? shallowEqual
+ ),
+ useWorkbenchCommands: () => store().commands,
+ useWorkbenchQueries: () => store().queries,
+ };
+});
+
+import { CanvasColorFeed } from './CanvasColorFeed';
+import { armMaskTintTarget, clearMaskTintTarget } from './maskTintTarget';
+import { useMaskTintEditor, type MaskTintEditor } from './useMaskTintEditor';
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const i18n = createInstance();
+beforeAll(async () => {
+ const translation = (await (await fetch('/locales/en.json')).json()) as Record;
+ await i18n.init({ initAsync: false, lng: 'en', resources: { en: { translation } } });
+});
+
+const TINT = '#e07575';
+const PICKED = '#3a7bd5';
+type ShapeSource = Extract;
+const SHAPE_SOURCE = {
+ fill: '#ff0000',
+ height: 10,
+ kind: 'rect',
+ stroke: '#00ff00',
+ strokeWidth: 2,
+ type: 'shape',
+ width: 10,
+} satisfies ShapeSource;
+const TEXT_SOURCE = {
+ align: 'left',
+ color: '#123456',
+ content: 'Hello',
+ fontFamily: 'system-ui, sans-serif',
+ fontSize: 48,
+ fontWeight: 400,
+ lineHeight: 1.2,
+ type: 'text',
+} satisfies Extract;
+
+type Router = (hex: string, phase: ColorSamplePhase) => boolean;
+
+/** The Color pane's tint editor, mounted beside the feed as the pane mounts it beside the canvas. */
+const TintEditorProbe = ({
+ engine,
+ onEditor,
+}: {
+ engine: CanvasEngine;
+ onEditor: (editor: MaskTintEditor | null) => void;
+}) => {
+ onEditor(useMaskTintEditor(engine));
+ return null;
+};
+
+const wait = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+const cleanups: (() => void | Promise)[] = [];
+afterEach(async () => {
+ for (const cleanup of cleanups.splice(0).reverse()) {
+ await cleanup();
+ }
+ clearMaskTintTarget();
+ harness.store = null;
+});
+
+/**
+ * A real engine over a project holding a mask, a shape, a text layer and a paint layer, with `selected` selected. The feed's registered router is captured so tests drive picks the way the color-picker tool does.
+ */
+const setup = async (
+ selected: 'm0' | 'shape' | 'text' | 'paint',
+ { shapeSource = SHAPE_SOURCE, withTintEditor = false }: { shapeSource?: ShapeSource; withTintEditor?: boolean } = {}
+) => {
+ const store = createWorkbenchStore();
+ harness.store = store;
+ const projectId = store.getState().activeProjectId;
+ const layers: CanvasLayerContract[] = [
+ layerContract('shape', 'raster', { name: 'Shape', source: shapeSource }),
+ layerContract('text', 'raster', { name: 'Text', source: TEXT_SOURCE }),
+ layerContract('paint', 'raster', { name: 'Paint' }),
+ layerContract('m0', 'inpaint_mask', { name: 'Mask' }),
+ ];
+ store.commands.canvas.apply(projectId, {
+ document: { ...createEmptyCanvasDocument(), selectedLayerId: selected, stacks: stacksFrom(layers) },
+ type: 'replaceCanvasDocument',
+ });
+ const createEngine = (): CanvasEngine => {
+ const created = createCanvasEngine({
+ ensureProjectOnServer: () => Promise.resolve(),
+ imageResolver: () => Promise.resolve(new Blob()),
+ mutationPort: createCanvasProjectMutationPort(store, projectId),
+ projectId,
+ reportError: () => undefined,
+ });
+ cleanups.push(() => created.lifecycle.dispose());
+ return created;
+ };
+ const engine = createEngine();
+ const [screen, overlay] = [document.createElement('canvas'), document.createElement('canvas')];
+ for (const canvas of [screen, overlay]) {
+ canvas.width = 64;
+ canvas.height = 64;
+ document.body.append(canvas);
+ cleanups.push(() => canvas.remove());
+ }
+ engine.surface.attach(screen, overlay);
+
+ let router: Router | null = null;
+ const setRouter = engine.tools.setColorSampleRouter.bind(engine.tools);
+ vi.spyOn(engine.tools, 'setColorSampleRouter').mockImplementation((next) => {
+ router = next;
+ return setRouter(next);
+ });
+
+ const host = document.createElement('div');
+ document.body.append(host);
+ const root: Root = createRoot(host);
+ cleanups.push(async () => {
+ await act(() => root.unmount());
+ host.remove();
+ });
+ let tintEditor: MaskTintEditor | null = null;
+ const renderFeed = (feedEngine: CanvasEngine) =>
+ act(() => {
+ root.render(
+
+
+ {withTintEditor ? (tintEditor = editor)} /> : null}
+
+ );
+ });
+ await renderFeed(engine);
+ expect(router).not.toBeNull();
+
+ const document_ = () => store.getState().projects.find((project) => project.id === projectId)!.canvas.document;
+ const layer = (id: string) => engine.document.model()!.getLayer(id)!;
+ const canvasValues = () =>
+ getProjectWidgetValues(
+ store.getState().projects.find((project) => project.id === projectId)!,
+ 'canvas'
+ );
+ /** One pick: previews each color, then ends with `end` carrying the last one, as the tool does. */
+ const pick = (colors: readonly string[], end: 'commit' | 'cancel') =>
+ act(() => {
+ for (const hex of colors) {
+ router!(hex, 'preview');
+ }
+ router!(colors.at(-1)!, end);
+ });
+ return {
+ canvasValues,
+ document: document_,
+ engine,
+ layer,
+ createEngine,
+ pick,
+ projectId,
+ renderFeed,
+ router: () => router!,
+ store,
+ tintEditor: () => tintEditor,
+ };
+};
+
+const tintOf = (layer: CanvasLayerContract) => (layer.type === 'inpaint_mask' ? layer.mask.fill.color : null);
+const sourceOf = (layer: CanvasLayerContract) => (layer.type === 'raster' ? layer.source : null);
+
+describe('CanvasColorFeed Alt-pick routing', () => {
+ it('writes the armed mask tint as one undoable edit and leaves the pair alone', async () => {
+ const view = await setup('m0');
+ armMaskTintTarget('m0');
+ const pairBefore = view.canvasValues().activeColors;
+
+ await act(() => view.router()('#101010', 'preview'));
+ // A preview shows live, on the next frame, without recording history.
+ await expect.poll(() => tintOf(view.layer('m0'))).toBe('#101010');
+ expect(view.engine.history.getEntries().past).toEqual([]);
+
+ await act(() => {
+ view.router()(PICKED, 'preview');
+ view.router()(PICKED, 'commit');
+ });
+ expect(tintOf(view.layer('m0'))).toBe(PICKED);
+ expect(view.engine.history.getEntries().past).toEqual(['Fill']);
+ expect(view.canvasValues().activeColors).toEqual(pairBefore);
+
+ await act(() => view.engine.history.undo());
+ expect(tintOf(view.layer('m0'))).toBe(TINT);
+ });
+
+ it('restores the tint from before the pick on cancel', async () => {
+ const view = await setup('m0');
+ armMaskTintTarget('m0');
+ await view.pick(['#101010', PICKED], 'cancel');
+ expect(tintOf(view.layer('m0'))).toBe(TINT);
+ expect(view.engine.history.getEntries().past).toEqual([]);
+ });
+
+ it('records a pending Color pane tint edit before an Alt-pick, so the pick lands last as its own step', async () => {
+ const view = await setup('m0', { withTintEditor: true });
+ await act(() => armMaskTintTarget('m0'));
+ // A pane edit waits out its settle delay before recording; the pick arrives inside that window.
+ await act(() => view.tintEditor()!.commit('#111111'));
+ await view.pick([PICKED], 'commit');
+ expect(tintOf(view.layer('m0'))).toBe(PICKED);
+ expect(view.engine.history.getEntries().past).toEqual(['Fill', 'Fill']);
+
+ // The pane's settle delay passing changes nothing more.
+ await act(() => wait(500));
+ expect(tintOf(view.layer('m0'))).toBe(PICKED);
+ expect(view.engine.history.getEntries().past).toEqual(['Fill', 'Fill']);
+
+ await act(() => view.engine.history.undo());
+ expect(tintOf(view.layer('m0'))).toBe('#111111');
+ });
+
+ it('keeps the pick on its target when the selection moves mid-pick', async () => {
+ const view = await setup('m0');
+ armMaskTintTarget('m0');
+ await act(() => view.router()('#101010', 'preview'));
+ await act(() => {
+ view.store.commands.canvas.apply(view.projectId, { id: 'paint', type: 'setCanvasSelectedLayer' });
+ });
+ await act(() => view.router()(PICKED, 'commit'));
+ expect(tintOf(view.layer('m0'))).toBe(PICKED);
+ expect(view.canvasValues().activeColors).toBeUndefined();
+ });
+
+ it.each([
+ ['foreground', 'fill'],
+ ['background', 'stroke'],
+ ] as const)('writes a selected shape’s %s-target color to its %s in one undo step', async (target, field) => {
+ const view = await setup('shape');
+ await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: target }));
+ const pairBefore = view.canvasValues().activeColors;
+
+ await view.pick(['#101010', PICKED], 'commit');
+ expect(sourceOf(view.layer('shape'))).toMatchObject({ ...SHAPE_SOURCE, [field]: PICKED });
+ expect(view.engine.history.getEntries().past).toEqual(['Edit shape']);
+ expect(view.canvasValues().activeColors).toEqual(pairBefore);
+
+ await act(() => view.engine.history.undo());
+ expect(sourceOf(view.layer('shape'))).toEqual(SHAPE_SOURCE);
+ });
+
+ it.each([
+ ['foreground', 'fill', { ...SHAPE_SOURCE, fill: null }],
+ ['background', 'stroke', { ...SHAPE_SOURCE, stroke: null }],
+ ] as const)(
+ 'leaves a selected shape’s %s-target %s off when the shape has none, and picks into the pair',
+ async (target, _field, shapeSource) => {
+ const view = await setup('shape', { shapeSource });
+ await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: target }));
+
+ await view.pick(['#101010', PICKED], 'commit');
+
+ expect(sourceOf(view.layer('shape'))).toEqual(shapeSource);
+ expect(view.engine.history.getEntries().past).toEqual([]);
+ expect(view.canvasValues().activeColors).toMatchObject({ [target]: PICKED });
+ }
+ );
+
+ it('does not turn a shape’s stroke back on when it goes off mid-pick', async () => {
+ const view = await setup('shape');
+ await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: 'background' }));
+ await act(() => view.router()('#101010', 'preview'));
+
+ const strokeless = { ...SHAPE_SOURCE, stroke: null };
+ await act(() =>
+ view.store.commands.canvas.apply(view.projectId, {
+ document: {
+ ...view.document(),
+ stacks: stacksFrom([layerContract('shape', 'raster', { name: 'Shape', source: strokeless })]),
+ },
+ type: 'replaceCanvasDocument',
+ })
+ );
+ await act(() => view.router()(PICKED, 'commit'));
+
+ expect(sourceOf(view.layer('shape'))).toEqual(strokeless);
+ });
+
+ it('writes a selected text layer’s color in one undo step', async () => {
+ const view = await setup('text');
+ await view.pick([PICKED], 'commit');
+ expect(sourceOf(view.layer('text'))).toMatchObject({ color: PICKED });
+ expect(view.engine.history.getEntries().past).toEqual(['Edit text']);
+ });
+
+ it('writes the pair, with no history, when nothing is armed — the Gradient tool included', async () => {
+ const view = await setup('paint');
+ await act(() => view.engine.tools.setTool('gradient'));
+ await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: 'background' }));
+
+ await act(() => view.router()('#101010', 'preview'));
+ // The pair follows every preview, as before.
+ expect(view.canvasValues().activeColors).toEqual({ background: '#101010', foreground: '#000000' });
+ await act(() => view.router()(PICKED, 'commit'));
+ expect(view.canvasValues().activeColors).toEqual({ background: PICKED, foreground: '#000000' });
+ expect(view.engine.history.getEntries().past).toEqual([]);
+ });
+
+ it('restores the pair color from before the pick on cancel', async () => {
+ const view = await setup('paint');
+ await view.pick(['#101010', PICKED], 'cancel');
+ expect(view.canvasValues().activeColors).toEqual({ background: '#ffffff', foreground: '#000000' });
+ });
+
+ it('registers its router again with a new engine instance for the same project', async () => {
+ const view = await setup('paint');
+ const replacement = view.createEngine();
+ const registered: Router[] = [];
+ const setRouter = replacement.tools.setColorSampleRouter.bind(replacement.tools);
+ vi.spyOn(replacement.tools, 'setColorSampleRouter').mockImplementation((next) => {
+ registered.push(next);
+ return setRouter(next);
+ });
+ expect(replacement.projectId).toBe(view.engine.projectId);
+
+ await view.renderFeed(replacement);
+
+ expect(registered).toHaveLength(1);
+ await act(() => registered[0]!('#101010', 'preview'));
+ expect(view.canvasValues().activeColors).toEqual({ background: '#ffffff', foreground: '#101010' });
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx
index ac0f21c7096..583a40af96e 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx
@@ -1,16 +1,71 @@
+import type { ColorSamplePhase } from '@workbench/canvas-engine/api';
import type { CanvasEngineHandle } from '@workbench/canvas-operations/react';
-import { useEffect } from 'react';
+import { useMountEffect } from '@platform/react/useMountEffect';
+import { usePreparedCommit, useStructuralPreview } from '@workbench/widgets/canvas/useStructuralCommit';
+import { getProjectWidgetValues } from '@workbench/widgetState';
+import { useWorkbenchQueries } from '@workbench/WorkbenchContext';
+import { useEffect, useEffectEvent, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { ActiveColorTarget } from './colorPair';
+
+import { readActiveColorPair, readActiveColorTarget } from './colorPair';
+import { maskTintTargetStore, settlePendingMaskTint } from './maskTintTarget';
import { useActiveColorCommands, useActiveColorPair } from './useActiveColors';
+import {
+ armedMaskLayer,
+ isMaskLayer,
+ MASK_TINT_EDIT_LABEL_KEY,
+ maskTintPreview,
+ prepareMaskTintEdit,
+} from './useMaskTintEditor';
+
+/**
+ * What one eyedropper pick writes, fixed at its first sample so a selection change mid-pick cannot retarget it.
+ * Gradient stops have no armed state, so a pick on the Gradient tool writes the pair. A live text session is no
+ * target: its editor holds focus and keeps Alt, and leaving it commits the session.
+ */
+type PickTarget =
+ | { kind: 'mask-tint'; layerId: string }
+ | { kind: 'shape'; layerId: string; field: 'fill' | 'stroke' }
+ | { kind: 'text-layer'; layerId: string }
+ | { kind: 'pair'; target: ActiveColorTarget; before: string };
+
+/**
+ * Precedence: the armed mask tint while its mask is still selected; a selected shape (the foreground target writes
+ * its fill, the background its stroke) when it has that slot; a selected text layer; otherwise the pair. A shape's
+ * off slot is no target, as in its own options, where the toggle alone turns a slot on.
+ */
+const resolvePickTarget = (engine: CanvasEngineHandle, canvasValues: Record): PickTarget => {
+ const model = engine.document.model();
+ const document = model?.document ?? null;
+ const pairTarget = readActiveColorTarget(canvasValues);
+ if (document) {
+ const tint = armedMaskLayer(document, maskTintTargetStore.getSnapshot().layerId);
+ if (tint) {
+ return { kind: 'mask-tint', layerId: tint.id };
+ }
+ }
+ const selected = document?.selectedLayerId ? (model?.getLayer(document.selectedLayerId) ?? null) : null;
+ if (selected?.type === 'raster' && selected.source.type === 'shape') {
+ const field = pairTarget === 'foreground' ? 'fill' : 'stroke';
+ if (selected.source[field] !== null) {
+ return { field, kind: 'shape', layerId: selected.id };
+ }
+ }
+ if (selected?.type === 'raster' && selected.source.type === 'text') {
+ return { kind: 'text-layer', layerId: selected.id };
+ }
+ return { before: readActiveColorPair(canvasValues)[pairTarget], kind: 'pair', target: pairTarget };
+};
/**
* Isolate frequent color-pair updates in this null adapter. Mirror the pair into brush color, captured at stroke
- * start, and route unclaimed samples to the active pair target.
+ * start, and route eyedropper picks to the armed color target.
*/
export const CanvasColorFeed = ({ engine }: { engine: CanvasEngineHandle | null }) => {
const colorPair = useActiveColorPair();
- const colorCommands = useActiveColorCommands();
useEffect(() => {
if (!engine) {
@@ -31,15 +86,121 @@ export const CanvasColorFeed = ({ engine }: { engine: CanvasEngineHandle | null
engine.interaction.set('colorPair', { background: colorPair.background, foreground: colorPair.foreground });
}, [engine, colorPair.background, colorPair.foreground]);
- useEffect(() => {
- if (!engine) {
- return;
- }
- return engine.tools.setColorSampleRouter((hex) => {
- colorCommands.applySampledColor(hex);
- return true;
+ // Keyed by engine identity, so each engine instance (a project's engine recreated after disposal included) mounts
+ // its own routing and registers its router exactly once.
+ return engine ? : null;
+};
+
+// Created on first use: a module-scope global constructor call would make this module side-effectful.
+let engineKeys: WeakMap | null = null;
+let nextEngineKey = 0;
+/** A stable React key per engine object; held weakly, so a disposed engine's entry goes with it. */
+const engineKey = (engine: CanvasEngineHandle): string => {
+ engineKeys ??= new WeakMap();
+ let key = engineKeys.get(engine);
+ if (key === undefined) {
+ nextEngineKey += 1;
+ key = `engine-${nextEngineKey}`;
+ engineKeys.set(engine, key);
+ }
+ return key;
+};
+
+/** Owns the engine's eyedropper router and the pick in progress. */
+const ColorSampleRouting = ({ engine }: { engine: CanvasEngineHandle }) => {
+ const { t } = useTranslation();
+ const colorCommands = useActiveColorCommands();
+ const queries = useWorkbenchQueries();
+ const tintPreview = useStructuralPreview(engine);
+ const commitPrepared = usePreparedCommit(engine);
+ const pickRef = useRef(null);
+
+ /** Commits the shape's or text layer's color through the same edit and history label as its own picker. */
+ const commitSourceColor = (target: Extract, hex: string): void => {
+ const label =
+ target.kind === 'shape' ? t('widgets.canvas.toolOptions.shapeEdit') : t('widgets.canvas.toolOptions.textEdit');
+ commitPrepared(label, (model) => {
+ const layer = model.getLayer(target.layerId);
+ if (layer?.type !== 'raster') {
+ return { ids: [target.layerId], status: 'missing' };
+ }
+ if (target.kind === 'shape' && layer.source.type === 'shape') {
+ // The slot can go off mid-pick (an undo, say); the pick never turns it back on.
+ if (layer.source[target.field] === null) {
+ return { status: 'unchanged' };
+ }
+ return model.prepare({ id: layer.id, source: { ...layer.source, [target.field]: hex }, type: 'patch-source' });
+ }
+ if (target.kind === 'text-layer' && layer.source.type === 'text') {
+ return model.prepare({ id: layer.id, source: { ...layer.source, color: hex }, type: 'patch-source' });
+ }
+ return { ids: [target.layerId], status: 'missing' };
});
- }, [colorCommands, engine]);
+ };
+
+ // Previews are live and unrecorded; the release records one edit, and a cancel restores the color from before the
+ // pick. Shape and text-layer colors live in the layer source, which has no live preview, so they change on release.
+ const routeSample = useEffectEvent((hex: string, phase: ColorSamplePhase): boolean => {
+ const startsPick = pickRef.current === null;
+ const target =
+ pickRef.current ??
+ resolvePickTarget(engine, getProjectWidgetValues(queries.getSnapshot().activeProject, 'canvas'));
+ pickRef.current = phase === 'preview' ? target : null;
+ switch (target.kind) {
+ case 'mask-tint': {
+ if (phase === 'cancel') {
+ tintPreview.cancel();
+ break;
+ }
+ if (phase === 'preview') {
+ // The pane's own tint gesture records first: the canvas holds one preview at a time.
+ if (startsPick) {
+ settlePendingMaskTint();
+ }
+ // The held layer, even if the selection moved mid-pick.
+ const layer = engine.document.model()?.getLayer(target.layerId) ?? null;
+ if (isMaskLayer(layer)) {
+ tintPreview.preview(maskTintPreview(layer, hex));
+ }
+ break;
+ }
+ tintPreview.commit(t(MASK_TINT_EDIT_LABEL_KEY), (model, baseline) =>
+ prepareMaskTintEdit(model, baseline, target.layerId, hex)
+ );
+ break;
+ }
+ case 'shape':
+ case 'text-layer':
+ if (phase === 'commit') {
+ commitSourceColor(target, hex);
+ }
+ break;
+ case 'pair':
+ colorCommands.setPairColor(target.target, phase === 'cancel' ? target.before : hex);
+ break;
+ }
+ return true;
+ });
+
+ /** A pick cut short by unmount records nothing. */
+ const abandonPick = useEffectEvent((): void => {
+ if (pickRef.current?.kind === 'mask-tint') {
+ tintPreview.cancel();
+ }
+ pickRef.current = null;
+ });
+
+ // The parent keys this component by engine, so the router registers once per engine instance.
+ // The linter cannot see useMountEffect as an effect, so it rejects calling the effect events from it.
+ /* eslint-disable react-hooks/rules-of-hooks -- useMountEffect is the repository's explicit useEffect wrapper */
+ useMountEffect(() => {
+ const dispose = engine.tools.setColorSampleRouter((hex, phase) => routeSample(hex, phase));
+ return () => {
+ dispose();
+ abandonPick();
+ };
+ });
+ /* eslint-enable react-hooks/rules-of-hooks */
return null;
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts
index 38d1b739f0b..e4ec68aa627 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts
@@ -7,13 +7,38 @@ import { createExternalStore } from '@platform/state/externalStore';
*/
export const maskTintTargetStore = createExternalStore<{ layerId: string | null }>({ layerId: null });
+/**
+ * Records the Color pane's tint gesture that previews but has not yet recorded. The canvas allows one structural
+ * preview at a time, so an Alt-pick into the tint records this gesture before it starts its own; otherwise the
+ * pane's late settle would land after the pick and overwrite it.
+ */
+let pendingTintSettle: (() => void) | null = null;
+
registerAccountOwnedResource({
clear: () => {
maskTintTargetStore.setSnapshot({ layerId: null });
+ pendingTintSettle = null;
},
name: 'mask-tint-target',
});
+/** Registers `settle` as the gesture an Alt-pick must record first. */
+export const holdPendingMaskTint = (settle: () => void): void => {
+ pendingTintSettle = settle;
+};
+
+/** Forgets `settle` once its gesture recorded, leaving a newer gesture's registration in place. */
+export const releasePendingMaskTint = (settle: () => void): void => {
+ if (pendingTintSettle === settle) {
+ pendingTintSettle = null;
+ }
+};
+
+/** Records any pending pane tint gesture now, before another tint preview begins. */
+export const settlePendingMaskTint = (): void => {
+ pendingTintSettle?.();
+};
+
export const armMaskTintTarget = (layerId: string): void => {
maskTintTargetStore.setSnapshot({ layerId });
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts
index d6a65e53955..f92d4e46a6f 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts
@@ -1,13 +1,25 @@
-import type { CanvasLayerContract, CanvasMaskFillContract } from '@workbench/canvas-engine/api';
+import type {
+ CanvasDocumentContractV3,
+ CanvasDocumentModel,
+ CanvasLayerContract,
+ CanvasLayerPreviewMutation,
+ CanvasMaskFillContract,
+ PrepareEditResult,
+} from '@workbench/canvas-engine/api';
import type { CanvasEngineHandle } from '@workbench/canvas-operations/react';
import { getDocumentLayer } from '@workbench/canvas-engine/api';
import { baselineConfig, useStructuralPreview } from '@workbench/widgets/canvas/useStructuralCommit';
import { useActiveProjectSelector } from '@workbench/WorkbenchContext';
-import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { clearMaskTintTarget, useMaskTintTargetLayerId } from './maskTintTarget';
+import {
+ clearMaskTintTarget,
+ holdPendingMaskTint,
+ releasePendingMaskTint,
+ useMaskTintTargetLayerId,
+} from './maskTintTarget';
type MaskTintEngine = Pick;
type MaskLayer = Extract;
@@ -35,7 +47,7 @@ interface TintGesture {
timer: ReturnType | null;
}
-const isMaskLayer = (layer: CanvasLayerContract | null): layer is MaskLayer =>
+export const isMaskLayer = (layer: CanvasLayerContract | null): layer is MaskLayer =>
layer?.type === 'inpaint_mask' || layer?.type === 'regional_guidance';
const configFor = (layer: MaskLayer, fill: CanvasMaskFillContract) =>
@@ -43,6 +55,40 @@ const configFor = (layer: MaskLayer, fill: CanvasMaskFillContract) =>
? ({ layerType: 'inpaint_mask', mask: { fill } } as const)
: ({ layerType: 'regional_guidance', mask: { fill } } as const);
+/** The history label every tint edit records under, whichever control or pick made it. */
+export const MASK_TINT_EDIT_LABEL_KEY = 'widgets.layers.maskFill.fill';
+
+/** The armed tint target's layer while it is still the selected mask; anything else disarms it. */
+export const armedMaskLayer = (document: CanvasDocumentContractV3, armedLayerId: string | null): MaskLayer | null => {
+ if (!armedLayerId || document.selectedLayerId !== armedLayerId) {
+ return null;
+ }
+ const node = getDocumentLayer(document, armedLayerId);
+ return node && isMaskLayer(node) ? node : null;
+};
+
+/** The un-recorded live preview of `layer` tinted `hex`. */
+export const maskTintPreview = (layer: MaskLayer, hex: string): CanvasLayerPreviewMutation => ({
+ config: configFor(layer, { ...layer.mask.fill, color: hex }),
+ id: layer.id,
+ type: 'updateCanvasLayerConfig',
+});
+
+/** The recorded tint edit for the live layer, its `before` taken from what a preview gesture replaced. */
+export const prepareMaskTintEdit = (
+ model: CanvasDocumentModel,
+ baseline: CanvasLayerPreviewMutation | null,
+ layerId: string,
+ hex: string
+): PrepareEditResult => {
+ const live = model.getLayer(layerId);
+ if (!isMaskLayer(live)) {
+ return { ids: [layerId], status: 'missing' };
+ }
+ const config = configFor(live, { ...live.mask.fill, color: hex });
+ return model.prepare({ before: baselineConfig(baseline, config), config, id: live.id, type: 'patch-config' });
+};
+
/**
* Clear invalid/unselected mask targets and return to pair editing. Settle each pending preview into exactly one
* history entry on idle, disarm, or unmount.
@@ -51,35 +97,27 @@ export const useMaskTintEditor = (engine: MaskTintEngine | null): MaskTintEditor
const { t } = useTranslation();
const { commit: commitPrepared, preview: previewStructural } = useStructuralPreview(engine);
const armedLayerId = useMaskTintTargetLayerId();
- const layer = useActiveProjectSelector((project): MaskLayer | null => {
- if (!armedLayerId || project.canvas.document.selectedLayerId !== armedLayerId) {
- return null;
- }
- const node = getDocumentLayer(project.canvas.document, armedLayerId);
- return node && (node.type === 'inpaint_mask' || node.type === 'regional_guidance') ? node : null;
- });
+ const layer = useActiveProjectSelector((project) => armedMaskLayer(project.canvas.document, armedLayerId));
const gestureRef = useRef(null);
+ const settleRef = useRef<() => void>(() => undefined);
+ // One stable identity for the shared pending-gesture slot, always running the latest settle.
+ const [settleLatest] = useState(() => () => settleRef.current());
const settle = useCallback(() => {
const gesture = gestureRef.current;
gestureRef.current = null;
if (!gesture) {
return;
}
+ releasePendingMaskTint(settleLatest);
if (gesture.timer !== null) {
clearTimeout(gesture.timer);
}
// A gesture ending on the color it started from prepares nothing, which drops its previews.
- commitPrepared(t('widgets.layers.maskFill.fill'), (model, baseline) => {
- const live = model.getLayer(gesture.layerId);
- if (!isMaskLayer(live)) {
- return { ids: [gesture.layerId], status: 'missing' };
- }
- const config = configFor(live, { ...live.mask.fill, color: gesture.latestHex });
- return model.prepare({ before: baselineConfig(baseline, config), config, id: live.id, type: 'patch-config' });
- });
- }, [commitPrepared, t]);
- const settleRef = useRef(settle);
+ commitPrepared(t(MASK_TINT_EDIT_LABEL_KEY), (model, baseline) =>
+ prepareMaskTintEdit(model, baseline, gesture.layerId, gesture.latestHex)
+ );
+ }, [commitPrepared, settleLatest, t]);
useEffect(() => {
settleRef.current = settle;
}, [settle]);
@@ -103,22 +141,17 @@ export const useMaskTintEditor = (engine: MaskTintEngine | null): MaskTintEditor
if (!layer) {
return;
}
- if (
- !previewStructural({
- config: configFor(layer, { ...layer.mask.fill, color: hex }),
- id: layer.id,
- type: 'updateCanvasLayerConfig',
- })
- ) {
+ if (!previewStructural(maskTintPreview(layer, hex))) {
return;
}
if (gestureRef.current === null) {
gestureRef.current = { latestHex: hex, layerId: layer.id, timer: null };
+ holdPendingMaskTint(settleLatest);
} else {
gestureRef.current.latestHex = hex;
}
},
- [previewStructural, layer]
+ [previewStructural, layer, settleLatest]
);
const commit = useCallback(
(hex: string) => {
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts
index 0bd2c2fae84..5f6975b48eb 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts
@@ -184,16 +184,6 @@ export const useCanvasDocumentEditingLocked = (engine: CanvasCoreStoreCapability
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
-/** Re-renders when any live layer cache gains, loses, or changes pixels. */
-export const useCanvasLayerPixelEpoch = (engine: CanvasCoreStoreCapability | null): number => {
- const subscribe = useCallback(
- (listener: () => void) => engine?.interaction.subscribe('layerPixelEpoch', listener) ?? (() => undefined),
- [engine]
- );
- const getSnapshot = useCallback(() => engine?.interaction.get('layerPixelEpoch') ?? 0, [engine]);
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-};
-
/** Read/write brush options directly through engine.interaction; no reducer mirror exists. */
export const useBrushOptions = (engine: CanvasCoreStoreCapability): BrushOptions =>
useCanvasInteractionState(engine, 'brushOptions');
@@ -226,6 +216,10 @@ export const useBboxGrid = (engine: CanvasCoreStoreCapability): number => useCan
export const useTransformSession = (engine: CanvasCoreStoreCapability): TransformSession | null =>
useCanvasInteractionState(engine, 'transformSession');
+/** The layer a requested transform waits on until its stroke saves; null when nothing is waiting. */
+export const usePendingTransformLayerId = (engine: CanvasCoreStoreCapability): string | null =>
+ useCanvasInteractionState(engine, 'pendingTransformLayerId');
+
/** Whether a pixel selection currently exists (enables fill/erase/invert/deselect controls). */
export const useCanvasHasSelection = (engine: CanvasCoreStoreCapability): boolean =>
useCanvasInteractionState(engine, 'hasSelection');
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts
index b894c221019..b98f1318a6c 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts
@@ -118,21 +118,77 @@ describe('computeFitBboxToMasks', () => {
imageLayer('a', 0, 0, 500, 500), // ignored (not a mask)
inpaintMask('m', 100, 100, { height: 40, imageName: 'm-img', width: 40 }),
]);
- // mask bounds {100,100,40,40} → pad 8 → {92,92,56,56} → grid 8 → {96,96,48,48}.
- expect(computeFitBboxToMasks(doc, 8)).toEqual({ height: 48, width: 48, x: 96, y: 96 });
+ // mask bounds {100,100,40,40} → blur 0 pads 8 → 92..148 → grid 8 outward → 88..152.
+ expect(computeFitBboxToMasks(doc, 8, 0)).toEqual({ height: 64, width: 64, x: 88, y: 88 });
+ });
+
+ it('pads by the mask blur plus 8 so the feathered edge stays inside the frame', () => {
+ const doc = docWith([inpaintMask('m', 200, 200, { height: 100, imageName: 'm-img', width: 100 })]);
+ // Default blur 16 pads 24: {176,176,148,148}. Blur 0 pads 8: {192,192,116,116}.
+ expect(computeFitBboxToMasks(doc, 1, 16)).toEqual({ height: 148, width: 148, x: 176, y: 176 });
+ expect(computeFitBboxToMasks(doc, 1, 0)).toEqual({ height: 116, width: 116, x: 192, y: 192 });
+ });
+
+ it('grid-snaps the blur-padded rect outward', () => {
+ const doc = docWith([inpaintMask('m', 100, 100, { height: 40, imageName: 'm-img', width: 40 })]);
+ // Blur 16 pads 24 → 76..164; grid 8 floors the origin to 72 and ceils the far edge to 168.
+ expect(computeFitBboxToMasks(doc, 8, 16)).toEqual({ height: 96, width: 96, x: 72, y: 72 });
+ });
+
+ /** The frame holds the whole feathered mask: its bounds grown by the blur on every side. */
+ const expectContainsFeather = (
+ frame: { x: number; y: number; width: number; height: number } | null,
+ mask: { x: number; y: number; width: number; height: number },
+ blur: number
+ ) => {
+ expect(frame).not.toBeNull();
+ expect(frame!.x).toBeLessThanOrEqual(mask.x - blur);
+ expect(frame!.y).toBeLessThanOrEqual(mask.y - blur);
+ expect(frame!.x + frame!.width).toBeGreaterThanOrEqual(mask.x + mask.width + blur);
+ expect(frame!.y + frame!.height).toBeGreaterThanOrEqual(mask.y + mask.height + blur);
+ };
+
+ it.each([
+ // Grid 16, mask 105..205: padded 81..229 snaps to 80..240. Inward snapping gave 96..224, cutting 89..96.
+ [16, 105, { height: 160, width: 160, x: 80, y: 80 }],
+ // Grid 32, mask 121..221: padded 97..245 snaps to 96..256. Inward snapping gave 128..224, cutting the mask itself.
+ [32, 121, { height: 160, width: 160, x: 96, y: 96 }],
+ ] as const)('keeps the blurred edge inside a grid-%i frame', (grid, origin, expected) => {
+ const mask = { height: 100, width: 100, x: origin, y: origin };
+ const doc = docWith([inpaintMask('m', origin, origin, { height: 100, imageName: 'm-img', width: 100 })]);
+
+ const frame = computeFitBboxToMasks(doc, grid, 16);
+
+ expect(frame).toEqual(expected);
+ expectContainsFeather(frame, mask, 16);
+ });
+
+ it('contains the feathered mask at every offset on 16 and 32 px grids', () => {
+ for (const grid of [16, 32]) {
+ for (let offset = 0; offset < grid; offset += 1) {
+ const mask = { height: 37, width: 53, x: 200 + offset, y: 300 - offset };
+ const doc = docWith([inpaintMask('m', mask.x, mask.y, { height: 37, imageName: 'm-img', width: 53 })]);
+ const frame = computeFitBboxToMasks(doc, grid, 16);
+
+ expectContainsFeather(frame, mask, 16);
+ for (const value of [frame!.x, frame!.y, frame!.width, frame!.height]) {
+ expect(value % grid).toBe(0);
+ }
+ }
+ }
});
it('excludes regional-guidance masks (legacy fits inpaint_mask only)', () => {
const regional = maskLayer('regional_guidance', 'rg', 400, 400, { height: 40, imageName: 'rg-img', width: 40 });
const doc = docWith([regional, inpaintMask('m', 100, 100, { height: 40, imageName: 'm-img', width: 40 })]);
// Identical to the inpaint-only fit above: the regional mask contributes nothing.
- expect(computeFitBboxToMasks(doc, 8)).toEqual({ height: 48, width: 48, x: 96, y: 96 });
+ expect(computeFitBboxToMasks(doc, 8, 0)).toEqual({ height: 64, width: 64, x: 88, y: 88 });
// Regional masks alone ⇒ nothing to fit.
- expect(computeFitBboxToMasks(docWith([regional]), 8)).toBeNull();
+ expect(computeFitBboxToMasks(docWith([regional]), 8, 0)).toBeNull();
});
it('returns null when there are no visible inpaint masks', () => {
- expect(computeFitBboxToMasks(docWith([imageLayer('a', 0, 0, 100, 100)]), 8)).toBeNull();
- expect(computeFitBboxToMasks(docWith([inpaintMask('empty', 0, 0, null)]), 8)).toBeNull();
+ expect(computeFitBboxToMasks(docWith([imageLayer('a', 0, 0, 100, 100)]), 8, 16)).toBeNull();
+ expect(computeFitBboxToMasks(docWith([inpaintMask('empty', 0, 0, null)]), 8, 16)).toBeNull();
});
});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts
index e1bbaa95781..0352f53e2ab 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts
@@ -1,7 +1,7 @@
/**
- * Union enabled, renderable, nonempty layer bounds in document space. Mask fitting includes only inpaint masks and
- * adds {@link MASK_FIT_PADDING}; snap inward with {@link fitRectToGrid}. Return null for no content or a collapsed
- * result.
+ * Union enabled, renderable, nonempty layer bounds in document space. Fitting to layers snaps inward with
+ * {@link fitRectToGrid}. Mask fitting includes only inpaint masks, pads by the mask blur plus {@link MASK_FIT_MARGIN}
+ * and snaps outward, so no grid cuts the feathered edge. Return null for no content or a collapsed result.
*/
import {
@@ -15,8 +15,11 @@ import {
union,
} from '@workbench/canvas-engine/api';
-/** Fixed outward padding (document px) applied to the mask union before grid-fitting. */
-const MASK_FIT_PADDING = 8;
+/**
+ * Outward margin (document px) beyond the mask blur. The blur feathers the mask past its painted edge, so the frame
+ * has to clear the blur radius for the soft edge to land inside the generated region.
+ */
+const MASK_FIT_MARGIN = 8;
/**
* Snap inward: round origins up and sizes down. gridSize <= 1 uses integer rounding, matching legacy
@@ -31,6 +34,16 @@ export const fitRectToGrid = (rect: Rect, gridSize: number): Rect => {
return { height, width, x, y };
};
+/** Snap outward: round origins down and far edges up, so the result contains `rect`. */
+const expandRectToGrid = (rect: Rect, gridSize: number): Rect => {
+ const g = gridSize > 1 ? gridSize : 1;
+ const x = Math.floor(rect.x / g) * g;
+ const y = Math.floor(rect.y / g) * g;
+ const width = Math.ceil((rect.x + rect.width) / g) * g - x;
+ const height = Math.ceil((rect.y + rect.height) / g) * g - y;
+ return { height, width, x, y };
+};
+
/** Union document bounds of enabled, renderable, nonempty layers matching predicate; return null when none qualify. */
export const unionRenderableBounds = (
doc: CanvasDocumentContractV3,
@@ -61,22 +74,26 @@ export const computeFitBboxToLayers = (doc: CanvasDocumentContractV3, gridSize:
return isEmpty(fitted) ? null : fitted;
};
-/** The padded, grid-snapped bbox that fits the visible INPAINT masks (legacy parity), or `null` when there are none. */
+/**
+ * The grid-aligned frame that contains the visible INPAINT masks padded by `maskBlur` plus {@link MASK_FIT_MARGIN},
+ * or `null` when there are none. `maskBlur` is the project's clamped compositing mask blur. Snapping inward would cut
+ * up to a grid step less one pixel from each side, more than the margin on 16 and 32 px grids.
+ */
export const computeFitBboxToMasks = (
doc: CanvasDocumentContractV3,
gridSize: number,
- padding: number = MASK_FIT_PADDING
+ maskBlur: number
): Rect | null => {
const bounds = unionRenderableBounds(doc, (layer) => layer.type === 'inpaint_mask');
if (!bounds) {
return null;
}
+ const padding = maskBlur + MASK_FIT_MARGIN;
const expanded: Rect = {
height: bounds.height + padding * 2,
width: bounds.width + padding * 2,
x: bounds.x - padding,
y: bounds.y - padding,
};
- const fitted = fitRectToGrid(expanded, gridSize);
- return isEmpty(fitted) ? null : fitted;
+ return expandRectToGrid(expanded, gridSize);
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/frameIcons.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/frameIcons.ts
new file mode 100644
index 00000000000..e882b05e40d
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/frameIcons.ts
@@ -0,0 +1,17 @@
+import { createLucideIcon } from 'lucide-react';
+
+/**
+ * Fit frame to masks: the frame tool's viewfinder corners around a diamond, so it reads as the frame's own family and
+ * stays distinct from the inpaint-mask type icon. The corners are Lucide `Scan` 1.46.0's four paths, inlined because
+ * lucide-react exports no typed per-icon node; the icon inherits Lucide's sizing, stroke width and round joins.
+ */
+export const FitFrameToMasksIcon = createLucideIcon({
+ name: 'fit-frame-to-masks',
+ node: [
+ ['path', { d: 'M3 7V5a2 2 0 0 1 2-2h2', key: 'scan-top-left' }],
+ ['path', { d: 'M17 3h2a2 2 0 0 1 2 2v2', key: 'scan-top-right' }],
+ ['path', { d: 'M21 17v2a2 2 0 0 1-2 2h-2', key: 'scan-bottom-right' }],
+ ['path', { d: 'M7 21H5a2 2 0 0 1-2-2v-2', key: 'scan-bottom-left' }],
+ ['path', { d: 'M12 7.5 16.5 12 12 16.5 7.5 12Z', key: 'diamond' }],
+ ],
+});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts
index 95fffc232a4..49215e4bd46 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts
@@ -704,6 +704,22 @@ describe('runCanvasInvocation', () => {
]);
});
+ it.each([
+ [
+ { layerId: 'layer-7', layerName: 'Sky', status: 'unreadable' } as const,
+ '“Sky” could not be read (its image may have been deleted or damaged). Disable or delete that layer, then invoke again.',
+ ],
+ [{ status: 'not-ready' } as const, 'Some layers are still loading. Wait a moment, then invoke again.'],
+ ])('explains a %o capture and dispatches nothing', async (result, message) => {
+ const harness = makeHarness();
+ harness.deps.composeForGeneration = () => Promise.resolve(result);
+
+ await runCanvasInvocation(harness.deps);
+
+ expect(harness.submittedGraphs()).toHaveLength(0);
+ expect(harness.notices().map((notice) => notice.message)).toEqual([message]);
+ });
+
it('uses the frozen canvas and detached pixels after edits at composite, upload, and queue-dispatch boundaries', async () => {
let liveDocument = makeDoc([rasterLayer('frozen-layer')], 96);
liveDocument.bbox = { height: 64, width: 64, x: 7, y: 11 };
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts
index eacb2e38ff3..fc54e6031a6 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts
@@ -363,7 +363,11 @@ export const runCanvasInvocation = async (deps: RunCanvasInvocationDeps): Promis
? 'The canvas changed while generation was being prepared. Please invoke again.'
: composed.status === 'adjustment-refused'
? ADJUSTMENT_REFUSAL_NOTICES[composed.reason](composed.ownerName)
- : `The canvas could not be captured for generation (${composed.status}).`
+ : composed.status === 'unreadable'
+ ? `“${composed.layerName}” could not be read (its image may have been deleted or damaged). Disable or delete that layer, then invoke again.`
+ : composed.status === 'not-ready'
+ ? 'Some layers are still loading. Wait a moment, then invoke again.'
+ : `The canvas could not be captured for generation (${composed.status}).`
);
return;
}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx
index 1622dbab0bd..27f7ea04b5b 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx
@@ -160,6 +160,7 @@ describe('getFilterErrorTranslationKey', () => {
'source-adjustment-over-budget': true,
'source-adjustment-too-large': true,
'source-over-budget': true,
+ 'source-unreadable': true,
stale: true,
unsupported: true,
};
@@ -226,9 +227,11 @@ describe('filter operation form', () => {
expect(footer).toContain('Portrait · Raster layer');
expect(footer).toContain('role="status"');
- // Auto-process on: no Process verb; Reset then Apply then Cancel.
+ // Auto-process on: no Process verb; Reset settings then Apply then Cancel.
expect(footer).not.toContain('>Process<');
- expect(footer.indexOf('>Reset<')).toBeLessThan(footer.indexOf('>Apply<'));
+ expect(footer).not.toContain('>Reset<');
+ expect(footer.indexOf('>Reset settings<')).toBeGreaterThan(-1);
+ expect(footer.indexOf('>Reset settings<')).toBeLessThan(footer.indexOf('>Apply<'));
expect(footer.indexOf('>Apply<')).toBeLessThan(footer.indexOf('>Cancel<'));
});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx
index 5d66bfc5f95..279dc73fa2a 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx
@@ -88,6 +88,7 @@ const FILTER_ERROR_TRANSLATION_KEYS: Record = {
'source-adjustment-over-budget': 'widgets.layers.rasterFilter.sourceAdjustmentOverBudget',
'source-adjustment-too-large': 'widgets.layers.rasterFilter.sourceAdjustmentTooLarge',
'source-over-budget': 'widgets.layers.rasterFilter.sourceOverBudget',
+ 'source-unreadable': 'widgets.layers.rasterFilter.sourceUnreadable',
stale: 'widgets.layers.rasterFilter.stale',
unsupported: 'widgets.layers.rasterFilter.unsupported',
};
@@ -239,7 +240,7 @@ const FilterFooter = ({ engine, isExternalInteractionLocked }: ToolFooterProps)
/>
{session.autoProcess ? null : (