From d794ab1efa2040c46180ab4a56b8946910b6fdb2 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Thu, 15 Oct 2026 21:48:44 -0600 Subject: [PATCH 1/8] fix(canvas): preview the recommended control filter when a model is chosen Auto-process filter sessions now run their starting settings, so Apply is no longer stuck disabled. Choosing a ControlNet model on an empty control layer no longer opens a filter session. --- .../filterOperationSession.test.ts | 14 ++++++ .../filterOperationSession.ts | 4 ++ .../ControlLayerSettings.browser.test.tsx | 44 +++++++++++++++++-- .../widgets/layers/ControlLayerSettings.tsx | 6 ++- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts index 9bd98822d8a..c25cf6208ec 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts @@ -371,6 +371,20 @@ describe('auto-process', () => { await vi.waitFor(() => expect(deps.runFilter).toHaveBeenCalledTimes(times)); }; + it('previews the starting draft without waiting for an edit', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS); + await flushScheduledRun(deps, 1); + expect(deps.runFilter).toHaveBeenCalledWith( + expect.objectContaining({ filterType: 'canny_edge_detection', settings: { radius: 2 } }) + ); + await vi.waitFor(() => expect(session.getSnapshot().preview?.imageName).toBe('filtered')); + session.dispose(); + }); + it('debounces draft updates into a single process run with the latest settings', async () => { vi.useFakeTimers(); const deps = createDeps(); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts index 62ee6e2d8db..54fdc986ac4 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts @@ -325,6 +325,10 @@ export const createFilterOperationSession = ( publish({ ...state, error: null, preview: null, status: 'ready' }); }; + // Auto-process covers the starting draft, not only later edits: the footer hides Process while it is on, so without + // this run Apply waits on a preview that never comes and only Reset and Cancel stay enabled. + scheduleAutoProcess(); + return { blockCommit: () => { if (commitController) { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.browser.test.tsx index f92f7435123..f8a9456746d 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.browser.test.tsx @@ -111,13 +111,14 @@ const modelOf = (layers: readonly CanvasLayerContract[], editRevision = 0) => * `replaceLayers` swaps the document the panel reads, as an undo landing mid-gesture would. */ const engineWith = ( - layers: CanvasControlLayerContract[] + layers: CanvasControlLayerContract[], + { hasContent = true, operations = {} }: { hasContent?: boolean; operations?: object } = {} ): Engine & { replaceLayers(next: CanvasLayerContract[]): void } => { let revision = 0; let model = modelOf(layers); const engine = { document: { model: () => model }, - exports: { hasExportableLayerContent: () => true }, + exports: { hasExportableLayerContent: () => hasContent }, interaction: { get: () => editingLocked, subscribe: () => () => undefined }, layers: { beginStructuralPreview: () => { @@ -155,7 +156,7 @@ const engineWith = ( model = modelOf(next, (revision += 1)); }, } as unknown as Engine & { replaceLayers(next: CanvasLayerContract[]): void }; - attachCanvasOperations(engine, {} as never); + attachCanvasOperations(engine, operations as never); return engine; }; @@ -530,3 +531,40 @@ describe('ControlLayerSettings weight', () => { await expect.element(weightSlider()).toHaveAttribute('aria-disabled', 'true'); }); }); + +describe('ControlLayerSettings model choice', () => { + const CANNY = { + base: 'sd-1', + default_settings: { preprocessor: 'canny_image_processor' }, + key: 'sd1-canny', + name: 'SD 1.5 Canny', + type: 'controlnet', + }; + const modelless = createControlLayer('Control 1', 'control-1', 'sd-1', null); + + const chooseCanny = async (hasContent: boolean) => { + catalog.models = [CANNY]; + getArchitectureCapabilities.mockResolvedValueOnce(architectureCapabilitiesFixture); + await settle(ensureArchitectureCapabilitiesLoaded); + const startFilterOperation = vi.fn(() => 'started' as const); + await render(modelless, engineWith([modelless], { hasContent, operations: { startFilterOperation } })); + await act(() => page.getByRole('combobox', { name: 'widgets.layers.control.model' }).click()); + await act(() => page.getByRole('option', { name: 'SD 1.5 Canny' }).click()); + await settle(); + return startFilterOperation; + }; + + it("starts the model's recommended filter on a layer with pixels", async () => { + const startFilterOperation = await chooseCanny(true); + + expect(commits.map(({ label }) => label)).toEqual(['widgets.layers.control.model']); + expect(startFilterOperation).toHaveBeenCalledExactlyOnceWith('control-1', 'canny_edge_detection'); + }); + + it('only sets the model on a layer without pixels yet, leaving nothing to filter', async () => { + const startFilterOperation = await chooseCanny(false); + + expect(commits.map(({ label }) => label)).toEqual(['widgets.layers.control.model']); + expect(startFilterOperation).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx index a3f590d48c6..397d83148e0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx @@ -198,9 +198,11 @@ export const ControlLayerSettings = ({ engine, layer, onOperationStarted }: Cont commitAdapter({ model }, t('widgets.layers.control.model')); const selected = models.find((candidate) => candidate.key === model); const recommendation = resolveDefaultFilterForModel(selected); - if (recommendation && !layer.filter) { + // As the Filter button: a layer without pixels has nothing to filter, and choosing the model before adding + // the control image is the usual order. + if (recommendation && !layer.filter && engine?.exports.hasExportableLayerContent(layer.id)) { runLayerFilterOperation( - () => (engine ? getCanvasOperations(engine).startFilterOperation(layer.id, recommendation) : 'not-ready'), + () => getCanvasOperations(engine).startFilterOperation(layer.id, recommendation), onOperationStarted ); } From d833039b6ea31ebcd036dcad53a6a749fcbfe70b Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Thu, 15 Oct 2026 21:48:44 -0600 Subject: [PATCH 2/8] fix(canvas): keep a custom render size at the locked aspect ratio Preset, swap, lock and locked bbox changes refit the custom render size at about the same area. Custom width and height link while the ratio is locked; free bbox drags leave them alone. --- .../performance/architecture-baseline.json | 1 + .../webv2/performance/browser-baseline.json | 2 + .../src/features/generation/core/settings.ts | 5 + .../webv2/src/features/generation/settings.ts | 1 + .../generation/ui/GenerateDimensionFields.tsx | 3 +- .../src/workbench/canvasDimsSync.test.ts | 173 ++++++++++++++++++ .../webv2/src/workbench/canvasDimsSync.ts | 149 +++++++++++---- .../GenerateCanvasRenderSize.browser.test.tsx | 33 +++- .../canvas/GenerateCanvasRenderSize.tsx | 44 +++-- .../canvas/invoke/canvasScaling.test.ts | 25 ++- .../widgets/canvas/invoke/canvasScaling.ts | 30 ++- invokeai/frontend/webv2/vite.config.mts | 2 + 12 files changed, 419 insertions(+), 49 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index e8a49c4183f..200f4a593a2 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -1058,6 +1058,7 @@ "source:src/workbench/widgets/canvas/bboxGrid.ts", "source:src/workbench/widgets/canvas/canvasSettings.ts", "source:src/workbench/widgets/canvas/invoke/canvasCompositing.ts", + "source:src/workbench/widgets/canvas/invoke/canvasScaling.ts", "source:src/workbench/widgets/canvas/invoke/canvasStrength.ts", "source:src/workbench/widgets/canvas/manifest.ts", "source:src/workbench/widgets/canvas/settingsContribution.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 9e45cb28593..94125d12ae5 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1766,6 +1766,7 @@ "source:src/workbench/widgets/canvas/bboxGrid.ts", "source:src/workbench/widgets/canvas/canvasSettings.ts", "source:src/workbench/widgets/canvas/invoke/canvasCompositing.ts", + "source:src/workbench/widgets/canvas/invoke/canvasScaling.ts", "source:src/workbench/widgets/canvas/invoke/canvasStrength.ts", "source:src/workbench/widgets/canvas/manifest.ts", "source:src/workbench/widgets/canvas/settingsContribution.ts", @@ -4169,6 +4170,7 @@ "source:src/workbench/widgets/canvas/bboxGrid.ts", "source:src/workbench/widgets/canvas/canvasSettings.ts", "source:src/workbench/widgets/canvas/invoke/canvasCompositing.ts", + "source:src/workbench/widgets/canvas/invoke/canvasScaling.ts", "source:src/workbench/widgets/canvas/invoke/canvasStrength.ts", "source:src/workbench/widgets/canvas/manifest.ts", "source:src/workbench/widgets/canvas/settingsContribution.ts", diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.ts index 4b5cf3c4809..e08cc3c74fa 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.ts @@ -122,6 +122,11 @@ export const calculateNewSize = ( return { height: clampDimension(exactWidth / ratio, grid), width: clampDimension(exactWidth, grid) }; }; +/** A preset ratio implies the lock; Free holds its ratio only while the lock is on. */ +export const isAspectRatioLocked = ( + settings: Pick +): boolean => settings.aspectRatioId !== 'Free' || settings.aspectRatioIsLocked; + /** Best-matching preset for existing dimensions, falling back to Free. */ export const deriveAspectRatioId = (width: number, height: number): AspectRatioId => { if (height <= 0) { diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 088197489f9..fa5fd3a213e 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -12,6 +12,7 @@ export { getDefaultLoraWeight, getModelDefaultVae, hasModelDefaultVae, + isAspectRatioLocked, isLoraCompatibleWithModel, isLoraModelConfig, isMainModelConfig, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx index 174c5ad2bf1..ec98116da29 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx @@ -8,6 +8,7 @@ import { calculateNewSize, clampDimension, deriveAspectRatioId, + isAspectRatioLocked, MAX_DIMENSION, MIN_DIMENSION, } from '@features/generation/core/settings'; @@ -265,7 +266,7 @@ export const GenerateDimensionFields = ({ const modelDefaults = selectedModel ? getDefaultGenerateSettings(selectedModel) : null; const dimensions = getGenerationDimensions(selectedModel); const dimensionGrid = dimensions.grid; - const isRatioConstrained = settings.aspectRatioId !== 'Free' || settings.aspectRatioIsLocked; + const isRatioConstrained = isAspectRatioLocked(settings); const displayDimensions = draftDimensions ?? { height: settings.height, width: settings.width }; const onCommitRef = useRef(onCommit); const pendingDimensionsRef = useRef(null); diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts index c9758606fed..4e8cd173d10 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts @@ -254,6 +254,7 @@ const setupCanvasStore = () => { store.commands.generation.patchSettings(...args); }, }, + widgets: store.commands.widgets, }, getState: store.getState, subscribe: store.subscribe, @@ -408,6 +409,7 @@ describe('createCanvasDimsSync (wiring)', () => { store.commands.generation.patchSettings(...args); }, }, + widgets: store.commands.widgets, }, getState: store.getState, subscribe: store.subscribe, @@ -470,6 +472,7 @@ describe('createCanvasDimsSync before the capability table arrives', () => { store.commands.generation.patchSettings(...args); }, }, + widgets: store.commands.widgets, }, getState: store.getState, subscribe: store.subscribe, @@ -538,3 +541,173 @@ describe('createCanvasDimsSync before the capability table arrives', () => { expect(getSyncDispatches()).toBe(0); }); }); + +describe('createCanvasDimsSync (manual render size)', () => { + seedArchitectureCapabilities(); + + const getRenderSize = (store: ReturnType) => { + const state = store.getState(); + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + const { scaledHeight, scaledWidth } = project ? getProjectWidgetValues(project, 'canvas') : {}; + return { height: scaledHeight, width: scaledWidth }; + }; + + /** A canvas project whose frame is locked to 3:2 and renders at a custom 1536x1024 (1,572,864 px). */ + const setupLockedCustom = () => { + const store = createWorkbenchStore(); + store.commands.generation.setSource('canvas'); + dispatchCanvas(store, { bbox: { height: 832, width: 1248, x: 0, y: 0 }, type: 'setCanvasBbox' }); + store.commands.generation.patchSettings({ + aspectRatioId: '3:2', + aspectRatioIsLocked: true, + aspectRatioValue: 3 / 2, + height: 832, + model, + modelKey: model.key, + width: 1248, + }); + const sync = createCanvasDimsSync(store); + store.commands.widgets.patchValues('canvas', { scaleMethod: 'manual', scaledHeight: 1024, scaledWidth: 1536 }); + return { store, sync }; + }; + + it('keeps a custom size that already holds the locked ratio', () => { + const { store, sync } = setupLockedCustom(); + + expect(getRenderSize(store)).toEqual({ height: 1024, width: 1536 }); + sync.dispose(); + }); + + it('reshapes the custom size to a newly chosen ratio at about the same area', () => { + const { store, sync } = setupLockedCustom(); + + store.commands.generation.patchSettings({ + aspectRatioId: '16:9', + aspectRatioValue: 16 / 9, + height: 768, + width: 1360, + }); + + // sqrt(1,572,864 × 16/9) ≈ 1672.2 wide and 940.6 tall, each to the nearest multiple of 8. + expect(getRenderSize(store)).toEqual({ height: 944, width: 1672 }); + sync.dispose(); + }); + + it('turns the custom size with the frame when width and height swap', () => { + const { store, sync } = setupLockedCustom(); + + store.commands.generation.patchSettings({ + aspectRatioId: '2:3', + aspectRatioValue: 2 / 3, + height: 1248, + width: 832, + }); + + expect(getRenderSize(store)).toEqual({ height: 1536, width: 1024 }); + sync.dispose(); + }); + + it('follows a bbox resize while the ratio is locked', () => { + const { store, sync } = setupLockedCustom(); + + dispatchCanvas(store, { bbox: { height: 700, width: 1000, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + // The bbox's 10:7 at the same area: ≈ 1499.0 × 1049.3. + expect(getRenderSize(store)).toEqual({ height: 1048, width: 1496 }); + sync.dispose(); + }); + + it('fits the custom size to the ratio when the lock engages', () => { + const store = createWorkbenchStore(); + store.commands.generation.setSource('canvas'); + dispatchCanvas(store, { bbox: { height: 800, width: 1040, x: 0, y: 0 }, type: 'setCanvasBbox' }); + store.commands.generation.patchSettings({ + aspectRatioId: 'Free', + aspectRatioIsLocked: false, + aspectRatioValue: 1.3, + height: 800, + model, + modelKey: model.key, + width: 1040, + }); + const sync = createCanvasDimsSync(store); + store.commands.widgets.patchValues('canvas', { scaleMethod: 'manual', scaledHeight: 1024, scaledWidth: 1536 }); + + store.commands.generation.patchSettings({ aspectRatioIsLocked: true }); + + // 1.3 at the same area: ≈ 1429.9 × 1099.95. + expect(getRenderSize(store)).toEqual({ height: 1096, width: 1432 }); + sync.dispose(); + }); + + it('fits a remembered custom size to the locked ratio when Custom is chosen again', () => { + const { store, sync } = setupLockedCustom(); + store.commands.widgets.patchValues('canvas', { scaleMethod: 'auto' }); + store.commands.generation.patchSettings({ + aspectRatioId: '16:9', + aspectRatioValue: 16 / 9, + height: 768, + width: 1360, + }); + // Auto renders at the frame, so the remembered size waits until Custom is chosen. + expect(getRenderSize(store)).toEqual({ height: 1024, width: 1536 }); + + store.commands.widgets.patchValues('canvas', { scaleMethod: 'manual' }); + + expect(getRenderSize(store)).toEqual({ height: 944, width: 1672 }); + sync.dispose(); + }); + + it('leaves the custom size alone while the ratio is free', () => { + const { store, sync } = setupLockedCustom(); + store.commands.generation.patchSettings({ aspectRatioId: 'Free', aspectRatioIsLocked: false }); + + store.commands.generation.patchSettings({ aspectRatioValue: 1.3, height: 800, width: 1040 }); + dispatchCanvas(store, { bbox: { height: 700, width: 1000, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + expect(getRenderSize(store)).toEqual({ height: 1024, width: 1536 }); + sync.dispose(); + }); + + it('leaves the custom size alone when a free bbox edit lands on a preset ratio, until the person locks it', () => { + const store = createWorkbenchStore(); + store.commands.generation.setSource('canvas'); + dispatchCanvas(store, { bbox: { height: 800, width: 1040, x: 0, y: 0 }, type: 'setCanvasBbox' }); + store.commands.generation.patchSettings({ + aspectRatioId: 'Free', + aspectRatioIsLocked: false, + aspectRatioValue: 1.3, + height: 800, + model, + modelKey: model.key, + width: 1040, + }); + const sync = createCanvasDimsSync(store); + store.commands.widgets.patchValues('canvas', { scaleMethod: 'manual', scaledHeight: 1024, scaledWidth: 1536 }); + + // The frame labels these bboxes 3:2 and 1:1, which the Size section shows as locked; the person never locked them. + dispatchCanvas(store, { bbox: { height: 832, width: 1248, x: 0, y: 0 }, type: 'setCanvasBbox' }); + expect(getActiveGenerate(store.getState()).values.aspectRatioId).toBe('3:2'); + expect(getRenderSize(store)).toEqual({ height: 1024, width: 1536 }); + dispatchCanvas(store, { bbox: { height: 1024, width: 1024, x: 0, y: 0 }, type: 'setCanvasBbox' }); + expect(getActiveGenerate(store.getState()).values.aspectRatioId).toBe('1:1'); + expect(getRenderSize(store)).toEqual({ height: 1024, width: 1536 }); + + // Choosing the 1:1 preset the frame already shows is a new lock at the same ratio. + store.commands.generation.patchSettings({ aspectRatioId: '1:1', aspectRatioIsLocked: true, aspectRatioValue: 1 }); + + // 1:1 at the same area: sqrt(1,572,864) ≈ 1254.1 per side, to the nearest multiple of 8. + expect(getRenderSize(store)).toEqual({ height: 1256, width: 1256 }); + sync.dispose(); + }); + + it('leaves the custom size alone when the canvas is not the invocation source', () => { + const { store, sync } = setupLockedCustom(); + store.commands.generation.setSource('generate'); + + store.commands.generation.patchSettings({ aspectRatioId: '16:9', aspectRatioValue: 16 / 9 }); + + expect(getRenderSize(store)).toEqual({ height: 1024, width: 1536 }); + sync.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts index eadac1740d8..f561434a2c7 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts @@ -1,20 +1,27 @@ /** * Sync only for Canvas sources. Bbox edits retain the exact footprint and derive model-grid processing dimensions; - * Generate edits resize the bbox at its top-left. Position-only changes and submit-only scaling do not - * participate. Capability changes retrigger reconciliation; update the expected snapshot before dispatch and guard - * re-entry. + * Generate edits resize the bbox at its top-left. Position-only changes do not participate. A manual render size + * follows the locked ratio when it changes, the lock engages, or Custom is chosen under a lock; a bbox edit carries it + * only under the person's own lock. Capability changes retrigger reconciliation; update the expected snapshot before + * dispatch and guard re-entry. */ -import type { AspectRatioId } from '@features/generation/contracts'; +import type { AspectRatioId, CanvasScaleMethod, GenerateModelConfig, PidMode } from '@features/generation/contracts'; import type { CanvasDocumentContractV3 } from '@workbench/canvas-engine/api'; import type { WorkbenchState } from '@workbench/projectContracts'; import { subscribeArchitectureCapabilities } from '@features/generation/runtime'; -import { clampDimension, deriveAspectRatioId } from '@features/generation/settings'; +import { clampDimension, deriveAspectRatioId, getGenerationDimensions } from '@features/generation/settings'; import type { WorkbenchCommands } from './workbenchStore'; import { resolveModelGrid } from './widgets/canvas/bboxGrid'; +import { + CANVAS_SCALING_KEYS, + fitCanvasScalingToRatio, + readCanvasScaling, + readLockedAspectRatio, +} from './widgets/canvas/invoke/canvasScaling'; import { getProjectWidgetValues } from './widgetState'; type Bbox = CanvasDocumentContractV3['bbox']; @@ -119,6 +126,7 @@ export interface CanvasDimsSyncStore { commands: { canvas: Pick; generation: Pick; + widgets: Pick; }; getState(): WorkbenchState; subscribe(listener: () => void): () => void; @@ -148,24 +156,35 @@ const readModelVariant = (values: Record): string | null => { : null; }; +// External generators follow their own size policy, so the type selects the grid as the compiler's does. +const readModelType = (values: Record): GenerateModelConfig['type'] => { + const model = values.model; + return model && typeof model === 'object' && (model as { type?: unknown }).type === 'external_image_generator' + ? 'external_image_generator' + : 'main'; +}; + export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync => { let prev: CanvasDimsSnapshot | null = null; let lastProjectId: string | null = null; + /** What the render size last saw of the active canvas project; null until it has seen one. */ + let renderPrev: { + isExplicitlyLocked: boolean; + method: CanvasScaleMethod; + projectId: string; + ratio: number | null; + } | null = null; let isSyncing = false; - const handleChange = (): void => { - // Dispatch synchronously re-enters this listener; skip the nested pass to bound writes. - if (isSyncing) { - return; - } - + /** Reconcile the frame; true when it wrote dimensions (and a derived ratio label) from a bbox edit. */ + const syncFrame = (): boolean => { const state = store.getState(); const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); if (!project) { prev = null; lastProjectId = null; - return; + return false; } if (project.id !== lastProjectId) { @@ -175,7 +194,7 @@ export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync if (project.invocation.sourceId !== 'canvas') { prev = null; - return; + return false; } const generateValues = getProjectWidgetValues(project, 'generate'); @@ -184,7 +203,7 @@ export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync if (width === null || height === null) { prev = null; - return; + return false; } const bbox = project.canvas.document.bbox; @@ -194,7 +213,7 @@ export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync // architecture. if (grid === null) { prev = null; - return; + return false; } const result = reconcileCanvasDims({ bbox, dims: { height, width }, grid, prev }); @@ -203,36 +222,102 @@ export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync switch (result.kind) { case 'none': { prev = createSnapshot(bbox, { height, width }, grid); - return; + return false; } case 'patch-dims': { const { kind: _, ...patch } = result; prev = createSnapshot(bbox, result, grid); - isSyncing = true; - try { - store.commands.generation.patchSettings(patch, projectId, 'system'); - } finally { - isSyncing = false; - } - return; + store.commands.generation.patchSettings(patch, projectId, 'system'); + return true; } case 'set-bbox': { const nextBbox = result.bbox; prev = createSnapshot(nextBbox, nextBbox, grid); - isSyncing = true; - try { - store.commands.canvas.apply(projectId, { bbox: nextBbox, type: 'setCanvasBbox' }, 'system'); - if (width !== nextBbox.width || height !== nextBbox.height) { - store.commands.generation.patchSettings(getDimsPatch(nextBbox.width, nextBbox.height), projectId, 'system'); - } - } finally { - isSyncing = false; + store.commands.canvas.apply(projectId, { bbox: nextBbox, type: 'setCanvasBbox' }, 'system'); + if (width !== nextBbox.width || height !== nextBbox.height) { + store.commands.generation.patchSettings(getDimsPatch(nextBbox.width, nextBbox.height), projectId, 'system'); } - return; + return false; } } }; + // Acts only on a new locked ratio or on entering Custom, never on a size being typed: the Size section links those. + const syncRenderSize = (isBboxDerived: boolean): void => { + const state = store.getState(); + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + + if (!project || project.invocation.sourceId !== 'canvas') { + renderPrev = null; + return; + } + + const generateValues = getProjectWidgetValues(project, 'generate'); + const base = readModelBase(generateValues); + const variant = readModelVariant(generateValues); + + // Hold the change until the architecture answers, as the frame does: a fallback grid may be invalid for it. + if (resolveModelGrid(base, variant) === null) { + return; + } + + const scaling = readCanvasScaling(getProjectWidgetValues(project, 'canvas')); + const ratio = readLockedAspectRatio(generateValues); + const isExplicitlyLocked = generateValues.aspectRatioIsLocked === true; + const previous = renderPrev?.projectId === project.id ? renderPrev : null; + renderPrev = { isExplicitlyLocked, method: scaling.method, projectId: project.id, ratio }; + + if ( + !previous || + ratio === null || + scaling.method !== 'manual' || + // The frame labels a bbox that lands on a preset with that preset, which reads as locked; only the person's own + // lock (a chosen preset or the lock button both set it) carries the render size along with a bbox edit. + (isBboxDerived && !isExplicitlyLocked) || + // Locking a ratio the frame only labelled is a new lock, even though the ratio itself is unchanged. + (previous.ratio === ratio && + previous.method === 'manual' && + (previous.isExplicitlyLocked || !isExplicitlyLocked)) || + // Without a size of its own, manual renders at the frame, which already holds the ratio. + (scaling.width === null && scaling.height === null) + ) { + return; + } + + const { bbox } = project.canvas.document; + const model = base ? { base, type: readModelType(generateValues), variant } : undefined; + const { grid } = getGenerationDimensions(model, (generateValues.pidMode as PidMode | undefined) ?? 'off'); + const fitted = fitCanvasScalingToRatio( + { height: scaling.height ?? bbox.height, width: scaling.width ?? bbox.width }, + ratio, + grid + ); + + if (fitted) { + store.commands.widgets.patchValues( + 'canvas', + { [CANVAS_SCALING_KEYS.height]: fitted.height, [CANVAS_SCALING_KEYS.width]: fitted.width }, + project.id, + 'system' + ); + } + }; + + const handleChange = (): void => { + // Dispatch synchronously re-enters this listener; skip the nested pass to bound writes. + if (isSyncing) { + return; + } + + isSyncing = true; + try { + // After the frame, so a ratio a bbox edit just wrote reaches the render size in the same pass. + syncRenderSize(syncFrame()); + } finally { + isSyncing = false; + } + }; + const unsubscribeStore = store.subscribe(handleChange); // Capability arrival may not change workbench state, so subscribe directly to retry the gated reconciliation. const unsubscribeCapabilities = subscribeArchitectureCapabilities(handleChange); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx index 722999b4060..8cf3479aa80 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx @@ -93,14 +93,16 @@ let root: Root | null = null; const createProject = ({ canvas = {}, frame, + generate = {}, model, }: { canvas?: Record; frame: { height: number; width: number }; + generate?: Record; model: Record; }): HarnessProject => ({ canvas: { document: { bbox: { ...frame, x: 0, y: 0 } } }, - values: { canvas, generate: { ...frame, model, pidMode: 'off' } }, + values: { canvas, generate: { ...frame, ...generate, model, pidMode: 'off' } }, }); const mount = async (children: ReactNode) => { @@ -305,6 +307,35 @@ describe('Size section in canvas mode', () => { expect(ghostSize()).toEqual([88, 44]); }); + it('keeps a custom render size at the locked ratio as either side is edited', async () => { + await renderSizeSection( + createProject({ + canvas: { scaleMethod: 'manual', scaledHeight: 768, scaledWidth: 1360 }, + frame: { height: 768, width: 1360 }, + generate: { aspectRatioId: '16:9', aspectRatioIsLocked: true, aspectRatioValue: 16 / 9 }, + model: SD1_MODEL, + }) + ); + const width = page.getByRole('spinbutton', { name: 'Render width' }); + const height = page.getByRole('spinbutton', { name: 'Render height' }); + + await userEvent.fill(width, '1023'); + // While typing, the other side follows unsnapped: 1023 × 9/16 ≈ 575.4. + expect(canvasValues()).toMatchObject({ scaledHeight: 575, scaledWidth: 1023 }); + await userEvent.keyboard('{Enter}'); + + // 1024 wide at 16:9 is exactly 576 tall. + expect(canvasValues()).toMatchObject({ scaledHeight: 576, scaledWidth: 1024 }); + await expect.element(height).toHaveValue('576'); + + await userEvent.fill(height, '900'); + await userEvent.keyboard('{Enter}'); + + // 900 snaps to 904; 904 × 16/9 ≈ 1607.1, which snaps to 1608. + expect(canvasValues()).toMatchObject({ scaledHeight: 904, scaledWidth: 1608 }); + 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. await renderSizeSection(createProject({ frame: { height: 384, width: 384 }, model: SD1_MODEL })); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.tsx index d6949d2c7fe..472f761fe54 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.tsx @@ -1,6 +1,7 @@ /** * Canvas mode's render size: generate at the frame's size, the model's optimal size, or a custom size, then resize - * the result back to the frame. The choice persists in canvas values for invocation compilation. + * the result back to the frame. The choice persists in canvas values for invocation compilation. A locked ratio + * links the custom sides; the workbench's canvas dims sync carries them to a new locked ratio. */ import type { NumberInput as ChakraNumberInput, SelectValueChangeDetails } from '@chakra-ui/react'; @@ -18,6 +19,7 @@ import { CANVAS_SCALE_METHODS, CANVAS_SCALING_KEYS, readCanvasScaling, + readLockedAspectRatio, } from '@workbench/widgets/canvas/invoke/canvasScaling'; import { getProjectWidgetValues } from '@workbench/widgetState'; import { useActiveProjectSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext'; @@ -53,6 +55,7 @@ const selectProcessingContext = (project: Project) => { return { bbox: project.canvas.document.bbox, committedFrame: { height: readSize(generate.height), width: readSize(generate.width) }, + lockedRatio: readLockedAspectRatio(generate), model: model && typeof model.base === 'string' ? { @@ -72,6 +75,7 @@ const processingContextEqual = (a: ProcessingContext, b: ProcessingContext): boo a.bbox.height === b.bbox.height && a.committedFrame.width === b.committedFrame.width && a.committedFrame.height === b.committedFrame.height && + a.lockedRatio === b.lockedRatio && a.model?.base === b.model?.base && a.model?.type === b.model?.type && a.model?.variant === b.model?.variant && @@ -139,22 +143,36 @@ export const GenerateCanvasRenderSize = ({ children, frame }: CanvasRenderSizePr [patch, processingSize, scaling.height, scaling.width] ); const snap = useCallback((value: number) => clampDimension(value, dimensions.grid), [dimensions.grid]); + const { lockedRatio } = context; // Typing needs the raw digits to land; the size snaps to the grid on commit // (Enter / blur), and the compiler clamps whatever is persisted meanwhile. + // A locked ratio derives the other side as the frame's own width and height do: unsnapped while typing, both + // sides snapped on commit. const sideHandlers = useCallback( - (key: string) => ({ - onValueChange: ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { - if (Number.isFinite(valueAsNumber)) { - patch({ [key]: valueAsNumber }); + (key: typeof CANVAS_SCALING_KEYS.height | typeof CANVAS_SCALING_KEYS.width) => { + const sidePatch = (value: number, shouldSnap: boolean): Record => { + const side = shouldSnap ? snap(value) : value; + if (lockedRatio === null) { + return { [key]: side }; } - }, - onValueCommit: ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { - if (Number.isFinite(valueAsNumber)) { - patch({ [key]: snap(valueAsNumber) }); - } - }, - }), - [patch, snap] + const other = key === CANVAS_SCALING_KEYS.width ? side / lockedRatio : side * lockedRatio; + const otherKey = key === CANVAS_SCALING_KEYS.width ? CANVAS_SCALING_KEYS.height : CANVAS_SCALING_KEYS.width; + return { [key]: side, [otherKey]: shouldSnap ? snap(other) : Math.round(other) }; + }; + return { + onValueChange: ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + patch(sidePatch(valueAsNumber, false)); + } + }, + onValueCommit: ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + patch(sidePatch(valueAsNumber, true)); + } + }, + }; + }, + [lockedRatio, patch, snap] ); const widthHandlers = useMemo(() => sideHandlers(CANVAS_SCALING_KEYS.width), [sideHandlers]); const heightHandlers = useMemo(() => sideHandlers(CANVAS_SCALING_KEYS.height), [sideHandlers]); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.test.ts index 07e4680c9cf..25e073d9991 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { readCanvasScaling } from './canvasScaling'; +import { readCanvasScaling, readLockedAspectRatio } from './canvasScaling'; describe('readCanvasScaling', () => { it('defaults to auto scaling with no manual size', () => { @@ -24,3 +24,26 @@ describe('readCanvasScaling', () => { }); }); }); + +describe('readLockedAspectRatio', () => { + it('holds a preset ratio, which implies the lock', () => { + expect(readLockedAspectRatio({ aspectRatioId: '16:9', aspectRatioIsLocked: false, aspectRatioValue: 16 / 9 })).toBe( + 16 / 9 + ); + }); + + it('holds a free ratio only while it is locked', () => { + expect(readLockedAspectRatio({ aspectRatioId: 'Free', aspectRatioIsLocked: true, aspectRatioValue: 1.3 })).toBe( + 1.3 + ); + expect( + readLockedAspectRatio({ aspectRatioId: 'Free', aspectRatioIsLocked: false, aspectRatioValue: 1.3 }) + ).toBeNull(); + expect(readLockedAspectRatio({ aspectRatioValue: 1.3 })).toBeNull(); + }); + + it('holds nothing without a usable ratio', () => { + expect(readLockedAspectRatio({ aspectRatioId: '1:1', aspectRatioValue: 0 })).toBeNull(); + expect(readLockedAspectRatio({ aspectRatioId: '1:1', aspectRatioValue: 'wide' })).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.ts index 9990151aa3f..2aafc155bd0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasScaling.ts @@ -1,6 +1,8 @@ /** Persist processing scale in canvas values for compilation; none preserves bbox size snapped to model grid. */ -import type { CanvasScaleMethod, CanvasScalingSettings } from '@features/generation/contracts'; +import type { AspectRatioId, CanvasScaleMethod, CanvasScalingSettings } from '@features/generation/contracts'; + +import { calculateNewSize, clampDimension, isAspectRatioLocked } from '@features/generation/settings'; export const CANVAS_SCALING_KEYS = { height: 'scaledHeight', @@ -27,3 +29,29 @@ export const readCanvasScaling = (values: Record | undefined): width: readSide(values?.[CANVAS_SCALING_KEYS.width]), }; }; + +/** The ratio a manual render size keeps: the Size section's ratio while it is locked, otherwise null. */ +export const readLockedAspectRatio = (generate: Record): number | null => { + const { aspectRatioId, aspectRatioIsLocked, aspectRatioValue } = generate; + const isLocked = isAspectRatioLocked({ + aspectRatioId: typeof aspectRatioId === 'string' ? (aspectRatioId as AspectRatioId) : 'Free', + aspectRatioIsLocked: aspectRatioIsLocked === true, + }); + + return isLocked && typeof aspectRatioValue === 'number' && Number.isFinite(aspectRatioValue) && aspectRatioValue > 0 + ? aspectRatioValue + : null; +}; + +/** + * Carry a manual render size to a new locked ratio at about the same area, on the model grid. Null when it already + * holds the ratio, meaning one side is the other's grid-snapped counterpart (as the Size section derives a locked side). + */ +export const fitCanvasScalingToRatio = ( + size: { height: number; width: number }, + ratio: number, + grid: number +): { height: number; width: number } | null => + clampDimension(size.width / ratio, grid) === size.height || clampDimension(size.height * ratio, grid) === size.width + ? null + : calculateNewSize(ratio, size.width * size.height, grid); diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index c060f6be154..e8cf662adf6 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -139,6 +139,8 @@ const EDITOR_BOOT_SHARED_MODULES = [ '/workbench/shell/topbar/LayoutPresetAdminDialogs.tsx', '/workbench/shell/topbar/LayoutPresetStrip.tsx', '/workbench/shell/topbar/ProjectSwitcher.tsx', + // The canvas dims sync reads the render-size keys at boot; the lazy canvas slots and compiler share them. + '/workbench/widgets/canvas/invoke/canvasScaling.ts', ] as const; // The workflow core, its UI barrel and the project workflow collection are one chunk: the editor boots with all of From 67687d8014bbb5f9d8907db498daa2f346906fa0 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Thu, 15 Oct 2026 21:48:44 -0600 Subject: [PATCH 3/8] fix(hotkeys): run image recall shortcuts from the gallery Recall, use-size and load-workflow keys now act on the selected image while Gallery or Preview has focus. Widget hotkey scopes accept several widget types; the gallery shortcut docs list the new keys. --- .../06.Gallery/gallery-reference.mdx | 9 +++- .../WorkbenchHotkeyRuntime.browser.test.tsx | 49 +++++++++++++++++++ .../hotkeys/WorkbenchHotkeyRuntime.test.ts | 7 ++- .../webv2/src/workbench/hotkeys/catalog.ts | 37 +++++++++----- .../src/workbench/hotkeys/extensionHotkeys.ts | 2 +- .../src/workbench/hotkeys/resolve.test.ts | 10 ++-- .../webv2/src/workbench/hotkeys/resolve.ts | 2 +- .../workbench/hotkeys/shortcutHints.test.ts | 2 +- .../webv2/src/workbench/hotkeys/types.ts | 3 +- .../settings/HotkeysSettingsSection.tsx | 4 +- 10 files changed, 99 insertions(+), 26 deletions(-) diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index 8d954d225e8..779e8385976 100644 --- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx +++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx @@ -1,7 +1,7 @@ --- title: Gallery Reference description: Details of the Gallery — the board menu, archiving, downloading and deleting boards, the media menu, Gallery settings and keyboard shortcuts. -lastUpdated: 2026-10-05 +lastUpdated: 2026-10-15 sidebar: order: 2 --- @@ -104,7 +104,14 @@ These work while the Gallery has focus: | **Escape** | Clear the selection | | **.** (period) | Star or unstar the selection | | **Delete** / **Backspace** | Delete the selection | +| **A** | Recall all of the selected image's settings | | **R** | Remix the selected image: recall its settings, except the seed | +| **P** | Recall the selected image's prompts | +| **S** | Recall the selected image's seed | +| **D** | Use the selected image's size | +| **W** | Load the workflow saved with the selected image | + +**A**, **R**, **P**, **S**, **D** and **W** also work while the Preview has focus. On macOS, use **Cmd** in place of **Ctrl**. See [Hotkeys](/users-guide/workbench/hotkeys/) for all shortcuts. diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx index 881d11ea191..d19ddd82c43 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx @@ -225,3 +225,52 @@ describe('WorkbenchHotkeyRuntime and toast buttons', () => { expect(ran).toEqual(['map.apply']); }); }); + +describe('WorkbenchHotkeyRuntime and selected-image shortcuts', () => { + /** A docked widget's DOM as the widget frame marks it for key targeting. */ + const mountWidget = (typeId: string, region: string) => { + const element = document.createElement('div'); + element.dataset.hotkeyWidgetInstanceId = typeId; + element.dataset.hotkeyWidgetRegion = region; + element.dataset.hotkeyWidgetTypeId = typeId; + host!.append(element); + return element; + }; + + beforeEach(() => { + // First-party commands are registered without a source, as `useRegisterFirstPartyCommands` does. + for (const id of [ + 'viewer.recallAll', + 'viewer.recallSeed', + 'viewer.recallPrompts', + 'viewer.remix', + 'gallery.remix', + ]) { + runtimeMocks.extensions.commands.register({ handler: () => ran.push(id), id, title: id }); + } + }); + + // Recall acts on the gallery selection, so its keys answer where the user selects it as well as in Preview. + it('recalls from the gallery selection with focus in Gallery or Preview', async () => { + const gallery = mountWidget('gallery', 'right'); + const preview = mountWidget('preview', 'center'); + + await pressKey('a', gallery); + await pressKey('s', gallery); + await pressKey('p', gallery); + await pressKey('s', preview); + + expect(ran).toEqual(['viewer.recallAll', 'viewer.recallSeed', 'viewer.recallPrompts', 'viewer.recallSeed']); + }); + + it('keeps remix on each widget’s own binding and recall out of other widgets', async () => { + const gallery = mountWidget('gallery', 'right'); + const preview = mountWidget('preview', 'center'); + + await pressKey('r', gallery); + await pressKey('r', preview); + await pressKey('s', host!.querySelector('[data-testid="window"]')!); + + expect(ran).toEqual(['gallery.remix', 'viewer.remix']); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts index a088ae7d018..e55cb682632 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts @@ -30,7 +30,10 @@ describe('getHotkeyExecutionSource', () => { it('uses the focused source for widget-scoped hotkeys', () => { expect( - getHotkeyExecutionSource(createHotkey({ scope: { kind: 'widget', typeId: 'test-widget' }, source }), activeSource) + getHotkeyExecutionSource( + createHotkey({ scope: { kind: 'widget', typeIds: ['test-widget'] }, source }), + activeSource + ) ).toBe(activeSource); }); }); @@ -69,7 +72,7 @@ describe('shouldPreventHotkeyDefault', () => { id: 'canvas.deselect', keys: ['mod+d'], preventDefault: undefined, - scope: { kind: 'widget', typeId: 'canvas' }, + scope: { kind: 'widget', typeIds: ['canvas'] }, }); const context: HotkeyContext = { activeInstanceId: 'canvas', diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index 5d5ee073a4d..25d8ae1c113 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -1,4 +1,4 @@ -import type { HotkeyCategory, HotkeyDefinition } from './types'; +import type { HotkeyCategory, HotkeyDefinition, HotkeyScope } from './types'; import { IS_MAC_OS } from './keys'; @@ -11,11 +11,17 @@ import { IS_MAC_OS } from './keys'; const categoryScopes = { app: { kind: 'global' }, - canvas: { kind: 'widget', typeId: 'canvas' }, - gallery: { kind: 'widget', typeId: 'gallery' }, - viewer: { kind: 'widget', typeId: 'preview' }, - workflows: { kind: 'widget', typeId: 'workflow' }, -} as const; + canvas: { kind: 'widget', typeIds: ['canvas'] }, + gallery: { kind: 'widget', typeIds: ['gallery'] }, + viewer: { kind: 'widget', typeIds: ['preview'] }, + workflows: { kind: 'widget', typeIds: ['workflow'] }, +} as const satisfies Record; + +/** + * Recall and workflow loading act on the gallery selection, which Preview shows, so they answer wherever the user + * selects or views it: Gallery or Preview. Remix stays per widget because Gallery binds its own. + */ +const selectedImageScope: HotkeyScope = { kind: 'widget', typeIds: ['preview', 'gallery'] }; const implemented = new Set([ 'app.cancelQueueItem', @@ -129,7 +135,12 @@ const formatHotkeyTitle = (id: string): string => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); -const hotkey = (category: HotkeyCategory, id: string, defaultKeys: string[]): HotkeyDefinition => { +const hotkey = ( + category: HotkeyCategory, + id: string, + defaultKeys: string[], + scope: HotkeyScope = categoryScopes[category] +): HotkeyDefinition => { const commandId = `${category}.${id}`; return { @@ -140,7 +151,7 @@ const hotkey = (category: HotkeyCategory, id: string, defaultKeys: string[]): Ho id: commandId, implemented: implemented.has(commandId), preventDefault: true, - scope: categoryScopes[category], + scope, title: formatHotkeyTitle(id), }; }; @@ -284,12 +295,12 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('viewer', 'swapImages', ['c']), hotkey('viewer', 'nextComparisonMode', ['m']), hotkey('viewer', 'deleteImage', ['delete', 'backspace']), - hotkey('viewer', 'loadWorkflow', ['w']), - hotkey('viewer', 'recallAll', ['a']), - hotkey('viewer', 'recallSeed', ['s']), - hotkey('viewer', 'recallPrompts', ['p']), + hotkey('viewer', 'loadWorkflow', ['w'], selectedImageScope), + hotkey('viewer', 'recallAll', ['a'], selectedImageScope), + hotkey('viewer', 'recallSeed', ['s'], selectedImageScope), + hotkey('viewer', 'recallPrompts', ['p'], selectedImageScope), hotkey('viewer', 'remix', ['r']), - hotkey('viewer', 'useSize', ['d']), + hotkey('viewer', 'useSize', ['d'], selectedImageScope), hotkey('viewer', 'toggleMetadata', ['i']), hotkey('gallery', 'selectAllOnPage', ['mod+a']), hotkey('gallery', 'clearSelection', ['esc']), diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/extensionHotkeys.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/extensionHotkeys.ts index c47e35eb259..15fa7ce8463 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/extensionHotkeys.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/extensionHotkeys.ts @@ -47,7 +47,7 @@ export const toExtensionHotkeyDefinition = (hotkey: WidgetHotkeyContribution): H } if (source) { - return { ...hotkey, category: 'app', implemented: true, scope: { kind: 'widget', typeId: source.typeId } }; + return { ...hotkey, category: 'app', implemented: true, scope: { kind: 'widget', typeIds: [source.typeId] } }; } return null; diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts index 513c220419e..8479ceb9fd4 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts @@ -41,7 +41,7 @@ describe('resolveHotkey', () => { commandId: 'widget', id: 'widget', keys: ['mod+arrowup'], - scope: { kind: 'widget', typeId: 'gallery' }, + scope: { kind: 'widget', typeIds: ['gallery'] }, title: 'Widget', }; const resolve = (activeWidgetTypeId: string | null) => @@ -68,7 +68,7 @@ describe('resolveHotkey', () => { ...base, commandId: 'widget', id: 'widget', - scope: { kind: 'widget', typeId: 'gallery' }, + scope: { kind: 'widget', typeIds: ['gallery'] }, title: 'Widget', }; @@ -87,7 +87,7 @@ describe('resolveHotkey', () => { ...base, commandId: 'widget', id: 'widget', - scope: { kind: 'widget', typeId: 'gallery' }, + scope: { kind: 'widget', typeIds: ['gallery'] }, title: 'Widget', }; const instanceHotkey: RegisteredHotkey = { @@ -176,7 +176,7 @@ describe('resolveHotkey', () => { const rightOnly = hotkey('right-only', { kind: 'focused-region', region: 'right' }); const thisWindow = hotkey('this-window', { floatingInstanceId: 'map-1', kind: 'focused-region' }); const otherWindow = hotkey('other-window', { floatingInstanceId: 'map-2', kind: 'focused-region' }); - const widget = hotkey('widget', { kind: 'widget', typeId: 'image-map' }); + const widget = hotkey('widget', { kind: 'widget', typeIds: ['image-map'] }); const instance = hotkey('instance', { instanceId: 'map-1', kind: 'instance' }); const resolve = (hotkeys: RegisteredHotkey[]) => resolveHotkey({ context: floating, event, hotkeys, matchedKey: 'x' })?.commandId; @@ -208,7 +208,7 @@ describe('resolveHotkey', () => { ...base, commandId: 'stale', id: 'stale', - scope: { kind: 'widget', typeId: 'gallery' }, + scope: { kind: 'widget', typeIds: ['gallery'] }, source: { instanceId: 'gallery-1', projectId: 'project-2', region: 'left', typeId: 'gallery' }, title: 'Stale project', }; diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts index 0a472858e46..89d1e81e462 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts @@ -27,7 +27,7 @@ const getScopePriority = (hotkey: RegisteredHotkey, context: HotkeyContext): num } if (scope.kind === 'widget') { - return scope.typeId === context.activeWidgetTypeId ? 300 : -1; + return context.activeWidgetTypeId && scope.typeIds.includes(context.activeWidgetTypeId) ? 300 : -1; } if (scope.kind === 'focused-region') { diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/shortcutHints.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/shortcutHints.test.ts index 54297987f68..11b3253bb4f 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/shortcutHints.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/shortcutHints.test.ts @@ -19,7 +19,7 @@ const command: HotkeyDefinition = { commandId: 'canvas.brushSizeDown', defaultKeys: ['['], id: 'canvas.brushSizeDown', - scope: { kind: 'widget', typeId: 'canvas' }, + scope: { kind: 'widget', typeIds: ['canvas'] }, title: 'Smaller brush', }; diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts index 717a53079e5..98371bb3099 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts @@ -10,7 +10,8 @@ export type HotkeyScope = * floating widget, whose window is no region others share — that one floating instance. */ | { kind: 'focused-region'; region?: WidgetRegion; floatingInstanceId?: WidgetInstanceId } - | { kind: 'widget'; typeId: WidgetTypeId } + /** Matches while the key press is aimed at a widget of one of these types. */ + | { kind: 'widget'; typeIds: readonly WidgetTypeId[] } | { kind: 'instance'; instanceId: WidgetInstanceId }; export interface HotkeyDefinition { diff --git a/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx b/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx index 3357884a6a9..bdc85be0358 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx @@ -84,7 +84,9 @@ const canScopesOverlap = (left: HotkeyDefinition, right: HotkeyDefinition): bool } if (left.scope.kind === 'widget' && right.scope.kind === 'widget') { - return left.scope.typeId === right.scope.typeId; + const rightTypeIds = right.scope.typeIds; + + return left.scope.typeIds.some((typeId) => rightTypeIds.includes(typeId)); } if (left.scope.kind === 'instance' && right.scope.kind === 'instance') { From bef340c31728494dca644536fd6f69b096141513 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Fri, 16 Oct 2026 00:40:07 -0600 Subject: [PATCH 4/8] fix(prompt): limit attention syntax to models that read Compel Highlight and Ctrl+Up/Down weighting only for SD 1.5, SD 2.x and SDXL prompts; other bases show plain text. One base table now picks both the editor behaviour and the graphs' Compel prompt nodes. --- .../advanced-prompt-syntax.mdx | 6 +- .../performance/architecture-baseline.json | 1 + .../webv2/performance/browser-baseline.json | 3 + .../src/features/generation/components.ts | 1 - .../core/canvas/addRegionalGuidance.ts | 8 +- .../src/features/generation/core/graph.ts | 3 +- .../generation/core/graphCoverage.test.ts | 14 +++ .../generation/core/prompt/compel.test.ts | 34 +++++++ .../features/generation/core/prompt/compel.ts | 27 ++++++ .../generation/core/prompt/highlight.test.ts | 25 ++++++ .../generation/core/prompt/highlight.ts | 14 ++- .../webv2/src/features/generation/graph.ts | 1 + .../DynamicPromptsButton.browser.test.tsx | 1 + .../ui/promptFields/DynamicPromptsButton.tsx | 5 +- .../DynamicPromptsControls.browser.test.tsx | 1 + .../ui/promptFields/NegativePromptField.tsx | 4 +- .../ui/promptFields/PositivePromptActions.tsx | 4 + .../PositivePromptField.browser.test.tsx | 63 +++++++++++++ .../ui/promptFields/PositivePromptField.tsx | 7 +- .../ui/promptFields/PromptTextarea.tsx | 22 +++-- ...izableTextareaIntegration.browser.test.tsx | 9 +- .../ui/promptFields/promptAttentionHotkeys.ts | 3 +- .../webv2/src/features/upscale/core/graph.ts | 3 +- .../WorkbenchHotkeyRuntime.browser.test.tsx | 90 ++++++++++++++++++- .../webv2/src/workbench/hotkeys/catalog.ts | 10 ++- .../workbench/hotkeys/resolve.browser.test.ts | 72 +++++++++++++++ .../webv2/src/workbench/hotkeys/resolve.ts | 5 ++ .../webv2/src/workbench/hotkeys/types.ts | 2 + .../src/workbench/widget-frame/README.md | 2 +- .../RegionalGuidanceSettings.browser.test.tsx | 11 +++ .../layers/RegionalGuidanceSettings.tsx | 9 +- 31 files changed, 422 insertions(+), 38 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/generation/core/prompt/compel.test.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts create mode 100644 invokeai/frontend/webv2/src/workbench/hotkeys/resolve.browser.test.ts diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/advanced-prompt-syntax.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/advanced-prompt-syntax.mdx index 6ab400146e6..8d0fe93c104 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/advanced-prompt-syntax.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/advanced-prompt-syntax.mdx @@ -1,7 +1,7 @@ --- title: Advanced Prompt Syntax description: Fine control over prompts — attention weighting, blending and conjunctions for SD 1.5, SD 2.x and SDXL, and the advanced dynamic prompt syntax for multiple choices, weights, variables and comments. -lastUpdated: 2026-09-30 +lastUpdated: 2026-10-15 sidebar: order: 3 --- @@ -13,7 +13,7 @@ Most prompts need nothing more than plain text, perhaps with a [prompt template] Attention weighting makes parts of the prompt count for more or less than the rest. It uses Compel syntax, which applies to **SD 1.5, SD 2.x and SDXL**. Newer model families, including FLUX, Z-Image, CogView4 and Qwen Image, do not use it, and the symbols are passed to the model as ordinary text. :::note[Compatibility note] - If a weighted prompt seems to be ignored, check which model you are using. The syntax in this section only affects SD 1.5, SD 2.x and SDXL. + If a weighted prompt seems to be ignored, check which model you are using. The syntax in this section only affects SD 1.5, SD 2.x and SDXL. The prompt field colors weights, groups and embeddings only while one of those models is selected; with any other model, or none, they appear as plain text. ::: | Syntax | Effect | @@ -55,7 +55,7 @@ Groups can be nested, and the weights combine. In `(portrait with (blue eyes)1.3 ### Adjusting weights with the keyboard -In a prompt field, select text and press **Ctrl+Up** or **Ctrl+Down** to raise or lower its attention. With no selection, the shortcut targets the word at the cursor. Selecting a group's parentheses or weight targets its contents. +In a prompt field, select text and press **Ctrl+Up** or **Ctrl+Down** to raise or lower its attention. With no selection, the shortcut targets the word at the cursor. Selecting a group's parentheses or weight targets its contents. The shortcut works while an SD 1.5, SD 2.x or SDXL model is selected; with any other model the keys keep their usual text-editing behavior. - A selection inside a group changes only the selected words. A selection crossing a group boundary changes the selected words on both sides. - Symbolic weights move one `+` or `-` step. Numeric weights change by `0.1` of effective attention, including inherited group weights. diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 200f4a593a2..2b757f140f0 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -558,6 +558,7 @@ "source:src/features/generation/core/projectPromptDraft.ts", "source:src/features/generation/core/prompt/ast.ts", "source:src/features/generation/core/prompt/attention.ts", + "source:src/features/generation/core/prompt/compel.ts", "source:src/features/generation/core/promptHistory.ts", "source:src/features/generation/core/promptTemplates.ts", "source:src/features/generation/core/referenceImage.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 94125d12ae5..3488f894710 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -982,6 +982,7 @@ "source:src/features/generation/core/projectPromptDraft.ts", "source:src/features/generation/core/prompt/ast.ts", "source:src/features/generation/core/prompt/attention.ts", + "source:src/features/generation/core/prompt/compel.ts", "source:src/features/generation/core/prompt/dynamicPromptSyntax.ts", "source:src/features/generation/core/prompt/highlight.ts", "source:src/features/generation/core/promptHistory.ts", @@ -2071,6 +2072,7 @@ "source:src/features/generation/core/projectPromptDraft.ts", "source:src/features/generation/core/prompt/ast.ts", "source:src/features/generation/core/prompt/attention.ts", + "source:src/features/generation/core/prompt/compel.ts", "source:src/features/generation/core/prompt/dynamicPromptSyntax.ts", "source:src/features/generation/core/prompt/highlight.ts", "source:src/features/generation/core/promptHistory.ts", @@ -3318,6 +3320,7 @@ "source:src/features/generation/core/projectPromptDraft.ts", "source:src/features/generation/core/prompt/ast.ts", "source:src/features/generation/core/prompt/attention.ts", + "source:src/features/generation/core/prompt/compel.ts", "source:src/features/generation/core/prompt/dynamicPromptSyntax.ts", "source:src/features/generation/core/prompt/highlight.ts", "source:src/features/generation/core/promptHistory.ts", diff --git a/invokeai/frontend/webv2/src/features/generation/components.ts b/invokeai/frontend/webv2/src/features/generation/components.ts index 9f1edafdb23..26f011e3c4c 100644 --- a/invokeai/frontend/webv2/src/features/generation/components.ts +++ b/invokeai/frontend/webv2/src/features/generation/components.ts @@ -7,7 +7,6 @@ export { export { NegativePromptField } from './ui/promptFields/NegativePromptField'; export { PositivePromptField } from './ui/promptFields/PositivePromptField'; export { PromptTextarea } from './ui/promptFields/PromptTextarea'; -export { PROMPT_ATTENTION_TARGET_PROPS } from './ui/promptFields/promptAttentionHotkeys'; export { SeedField, type SeedFieldProps } from './ui/shared/SeedField'; export { FluxReduxControls } from './ui/reference-images/ReferenceImageControls'; export { GenerateCollapsibleSection as GenerationSettingsSection } from './ui/shared/GenerateCollapsibleSection'; diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts index 4939bba2532..074350dfcc3 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts @@ -2,6 +2,7 @@ import type { BackendGraphContract, BackendInvocationContract } from '@features/ import { getArchitectureFeatures } from '@features/generation/core/architectureCapabilities'; import { addEdge, addNode } from '@features/generation/core/graphBuilder'; +import { COMPEL_PROMPT_NODE_TYPES } from '@features/generation/core/prompt/compel'; /** The deterministic denoise node id every canvas base graph uses. */ const DENOISE_NODE_ID = 'denoise_latents'; @@ -123,8 +124,10 @@ export interface AddRegionalGuidanceOptions { /** Per-base conditioning encoder node type + the fields carrying the prompt. */ const conditioningNodeType = (base: RegionalGuidanceBase, modelVariant?: string | null): string => { switch (base) { + case 'sd-1': + case 'sd-2': case 'sdxl': - return 'sdxl_compel_prompt'; + return COMPEL_PROMPT_NODE_TYPES[base]; case 'flux': return 'flux_text_encoder'; case 'flux2': @@ -135,9 +138,6 @@ const conditioningNodeType = (base: RegionalGuidanceBase, modelVariant?: string return 'z_image_text_encoder'; case 'anima': return 'anima_text_encoder'; - case 'sd-1': - case 'sd-2': - return 'compel'; } }; diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.ts index 02be3aae118..f0cf25ea5bf 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -53,6 +53,7 @@ import { } from './graphBuilder'; import { addKrea2ConditioningEnhancers } from './krea2Conditioning'; import { addPidDecode, getPidDenoiseSize, getPidMetadata, shouldUsePidDecode } from './pidGraph'; +import { getCompelPromptNodeType } from './prompt/compel'; import { getEffectiveReferenceImage } from './referenceImage'; const getCompatibleComponentSource = ( @@ -315,7 +316,7 @@ const buildSDGraph = ( ): BackendGraphContract => { const graph: BackendGraphContract = { edges: [], id: createId(`${model.base}_graph`), nodes: {} }; const modelLoaderType = model.base === 'sdxl' ? 'sdxl_model_loader' : 'main_model_loader'; - const compelType = model.base === 'sdxl' ? 'sdxl_compel_prompt' : 'compel'; + const compelType = getCompelPromptNodeType(model.base); const activeLoras = getActiveCompatibleLoras(settings, model); const scheduler = coerceSchedulerForGraph(model, settings.scheduler); const colorCompensation = settings.colorCompensation ? 'SDXL' : 'None'; diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts index 594703a4de0..aea40f5a32e 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -23,6 +23,7 @@ import { satisfiedSettingsFor, SHAPE_OVERRIDES, } from './graphCoverage.testing'; +import { supportsPromptAttentionSyntax } from './prompt/compel'; const compileForShape = ( base: SupportedGenerateBase, @@ -49,6 +50,19 @@ describe('generate graph coverage', () => { ); }); + // The prompt editor highlights and weights attention syntax by this capability; only Compel reads it. + it.each(generateGraphCases)( + 'encodes $label prompts with Compel exactly when attention syntax is offered', + ({ base, shape }) => { + const { graph } = compileForShape(base, shape); + const usesCompel = Object.values(graph.nodes).some( + (node) => node.type === 'compel' || node.type === 'sdxl_compel_prompt' + ); + + expect(usesCompel).toBe(supportsPromptAttentionSyntax(base)); + } + ); + it.each(generateGraphCases)('compiles a structurally sound graph for $label', ({ base, shape }) => { const { graph } = compileForShape(base, shape); const nodeIds = new Set(Object.keys(graph.nodes)); diff --git a/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.test.ts b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.test.ts new file mode 100644 index 00000000000..41c1da01f15 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { getCompelPromptNodeType, supportsPromptAttentionSyntax } from './compel'; + +describe('supportsPromptAttentionSyntax', () => { + it.each(['sd-1', 'sd-2', 'sdxl'])('reads attention syntax for %s', (base) => { + expect(supportsPromptAttentionSyntax(base)).toBe(true); + }); + + it.each(['flux', 'krea-2', 'flux2', 'sd-3', 'cogview4', 'z-image', 'qwen-image', 'anima', 'wan', 'external'])( + 'sends %s prompts as plain text', + (base) => { + expect(supportsPromptAttentionSyntax(base)).toBe(false); + } + ); + + it('reads nothing without a model or for inherited object keys', () => { + expect(supportsPromptAttentionSyntax(undefined)).toBe(false); + expect(supportsPromptAttentionSyntax(null)).toBe(false); + expect(supportsPromptAttentionSyntax('constructor')).toBe(false); + }); +}); + +describe('getCompelPromptNodeType', () => { + it('names each Compel base its own encoder node', () => { + expect(getCompelPromptNodeType('sd-1')).toBe('compel'); + expect(getCompelPromptNodeType('sd-2')).toBe('compel'); + expect(getCompelPromptNodeType('sdxl')).toBe('sdxl_compel_prompt'); + }); + + it('refuses a base dispatched to a Compel path by mistake', () => { + expect(() => getCompelPromptNodeType('flux')).toThrow('flux prompts are not encoded with Compel.'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts new file mode 100644 index 00000000000..76cb8b01e29 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts @@ -0,0 +1,27 @@ +import type { SupportedGenerateBase } from '@features/generation/core/supportedBases'; + +/** + * The Compel node that encodes each base's prompts. Compel is the only encoder that reads attention syntax: weights, + * groups, `.and()`/`.blend()` and ``. Every other base, external generators included, hands its text + * encoder the prompt as written. + */ +export const COMPEL_PROMPT_NODE_TYPES = { + 'sd-1': 'compel', + 'sd-2': 'compel', + sdxl: 'sdxl_compel_prompt', +} as const satisfies Partial>; + +export type CompelPromptBase = keyof typeof COMPEL_PROMPT_NODE_TYPES; + +/** No model, no reader: an unselected model interprets nothing. */ +export const supportsPromptAttentionSyntax = (base: string | null | undefined): base is CompelPromptBase => + typeof base === 'string' && Object.hasOwn(COMPEL_PROMPT_NODE_TYPES, base); + +/** For a graph path dispatched by base: throws rather than emit a conditioning node with no type. */ +export const getCompelPromptNodeType = (base: string): (typeof COMPEL_PROMPT_NODE_TYPES)[CompelPromptBase] => { + if (!supportsPromptAttentionSyntax(base)) { + throw new Error(`${base} prompts are not encoded with Compel.`); + } + + return COMPEL_PROMPT_NODE_TYPES[base]; +}; diff --git a/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.test.ts b/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.test.ts index e1d7af861e2..adac0858082 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.test.ts @@ -121,6 +121,31 @@ describe('prompt highlight segments', () => { expect(dynamicKindForText('{red+|green}', '+')).toBe('attention'); }); }); + + describe('for a model that reads the prompt as plain text', () => { + it('leaves weights, groups, embeddings, functions and bracket errors plain', () => { + const prompt = "cat+ (dog)1.2 ('a', 'b').and() \\( unclosed("; + + expect(buildPromptHighlightSegments(prompt, { attentionSyntax: false })).toEqual([ + { kind: 'text', range: { start: 0, end: prompt.length }, text: prompt }, + ]); + }); + + it('still marks dynamic-prompt syntax, which is expanded before any encoder reads the prompt', () => { + const prompt = '{red+|green} # note'; + const segments = buildPromptHighlightSegments(prompt, { attentionSyntax: false, dynamicPrompts: true }); + + expect(segments).toEqual([ + { kind: 'variantBrace', range: { start: 0, end: 1 }, text: '{' }, + { kind: 'text', range: { start: 1, end: 5 }, text: 'red+' }, + { kind: 'variantSeparator', range: { start: 5, end: 6 }, text: '|' }, + { kind: 'text', range: { start: 6, end: 11 }, text: 'green' }, + { kind: 'variantBrace', range: { start: 11, end: 12 }, text: '}' }, + { kind: 'text', range: { start: 12, end: 13 }, text: ' ' }, + { kind: 'comment', range: { start: 13, end: 19 }, text: '# note' }, + ]); + }); + }); }); // A variant-heavy fixture exposes quadratic annotation lookup. diff --git a/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.ts b/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.ts index 8ea378d6004..32308c75ad4 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/prompt/highlight.ts @@ -25,6 +25,11 @@ export type PromptHighlightKind = | 'error'; export interface PromptHighlightOptions { + /** + * False when the model reads the prompt as plain text: Compel's weights, groups, embeddings, functions and bracket + * errors stay plain. Dynamic-prompt syntax is expanded before any encoder sees the prompt, so it is unaffected. + */ + attentionSyntax?: boolean; /** Enable dynamic highlighting only on surfaces that actually expand syntax. */ dynamicPrompts?: boolean; /** Resolvable wildcard names; omitted leaves every `__name__` neutral. */ @@ -262,16 +267,19 @@ export const buildPromptHighlightSegments = ( } try { + const attentionSyntax = options.attentionSyntax ?? true; + // Compel tokens still segment the prompt, so dynamic annotations land on the same boundaries either way. const tokens = tokenizePrompt(prompt); const annotations = [ - ...collectAnnotations(prompt, parsePromptTokens(tokens)), - ...collectParenthesisErrors(tokens), + ...(attentionSyntax + ? [...collectAnnotations(prompt, parsePromptTokens(tokens)), ...collectParenthesisErrors(tokens)] + : []), ...(options.dynamicPrompts ? collectDynamicPromptAnnotations(prompt, options.knownWildcards) : []), ]; const segments: PromptHighlightSegment[] = []; forEachTokenAnnotation(tokens, annotations, (token, annotation) => { - const baseKind = tokenKind(token); + const baseKind = attentionSyntax ? tokenKind(token) : 'text'; const kind = annotation && annotation.priority > BASE_PRIORITY[baseKind] ? annotation.kind : baseKind; appendSegment(segments, { diff --git a/invokeai/frontend/webv2/src/features/generation/graph.ts b/invokeai/frontend/webv2/src/features/generation/graph.ts index b29991d4d7a..f58063886b4 100644 --- a/invokeai/frontend/webv2/src/features/generation/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/graph.ts @@ -15,6 +15,7 @@ export { toModelIdentifier, } from './core/graphBuilder'; export { detectCanvasMode } from './core/canvas/canvasMode'; +export { getCompelPromptNodeType, supportsPromptAttentionSyntax } from './core/prompt/compel'; export { areControlAdapterValuesValid, CONTROL_ADAPTER_KINDS, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx index 9562ca0ea07..c114af7608c 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx @@ -59,6 +59,7 @@ const render = async (prompt: string, onUsePrompt = vi.fn()) => { void; onInsertText: (text: string) => void; } @@ -28,6 +30,7 @@ interface DynamicPromptsButtonProps { export const DynamicPromptsButton = ({ batchCount, config, + highlightExpandedPrompts, onInsertText, onUsePrompt, positivePrompt, @@ -123,7 +126,7 @@ export const DynamicPromptsButton = ({ batchCount={batchCount} config={config} expansion={expansion} - showSyntaxHighlighting={showSyntaxHighlighting} + showSyntaxHighlighting={highlightExpandedPrompts} onUsePrompt={handleUsePrompt} /> ) : ( diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx index b6b0ce974be..18e29a759bf 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx @@ -62,6 +62,7 @@ const render = async (onChange: (patch: Partial) => void = void act(() => draw()); }, }} + highlightExpandedPrompts positivePrompt="a {red|green} cat" showSyntaxHighlighting onInsertText={vi.fn()} diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/NegativePromptField.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/NegativePromptField.tsx index da247edf3b0..269dff1966f 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/NegativePromptField.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/NegativePromptField.tsx @@ -3,6 +3,7 @@ import type { GenerateLora, GenerateModelConfig } from '@features/generation/cor import type { ChangeEvent, KeyboardEvent } from 'react'; import { HStack, Text } from '@chakra-ui/react'; +import { supportsPromptAttentionSyntax } from '@features/generation/core/prompt/compel'; import { getPromptTemplateChunks } from '@features/generation/core/promptTemplates'; import { useRegisterGenerateDraftFlusher } from '@features/generation/ui/generateDraftRegistry'; import { useDebouncedDraftValue } from '@features/generation/ui/useDebouncedDraftValue'; @@ -12,7 +13,6 @@ import { useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { AddPromptTriggerButton, PromptTriggerPopover } from './PositivePromptActions'; -import { PROMPT_ATTENTION_TARGET_PROPS } from './promptAttentionHotkeys'; import { insertPromptText } from './promptFocus'; import { promptHistoryNavigation } from './promptHistoryNavigation'; import { PromptTextarea } from './PromptTextarea'; @@ -184,9 +184,9 @@ export const NegativePromptField = ({ return ( void; showSyntaxHighlighting: boolean; + /** Whether the selected model reads Compel attention syntax, the only syntax left in an expanded prompt. */ + attentionSyntax: boolean; } export const PositivePromptActions = ({ + attentionSyntax, batchCount, droppedImage, dynamicPrompts, @@ -193,6 +196,7 @@ export const PositivePromptActions = ({ ('[aria-label="widgets.generate.expandPrompt"]')?.disabled).toBe(false); expect(host.querySelector('[aria-label="widgets.generate.imageToPrompt"]')?.disabled).toBe(false); }); + +it('colours and weights attention syntax only while the selected model reads it', async () => { + const sdxl: GenerateModelConfig = { base: 'sdxl', key: 'sdxl', name: 'SDXL', type: 'main' }; + const krea: GenerateModelConfig = { base: 'krea-2', key: 'krea', name: 'Krea-2 Turbo', type: 'main' }; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + + const render = (selectedModel: GenerateModelConfig) => + act(() => { + root?.render( + + + + + + + + + + ); + }); + const textarea = () => host!.querySelector('textarea')!; + const colouredSegments = () => [...host!.querySelectorAll('pre span')].map((span) => span.textContent); + /** Select "cat" and run the weighting command the Ctrl+Up hotkey runs. */ + const weightCat = async () => { + let weighted = false; + + textarea().focus(); + textarea().setSelectionRange(2, 5); + await act(() => { + weighted = adjustFocusedPromptAttention('increment', false); + }); + + return weighted; + }; + + await render(sdxl); + expect(colouredSegments()).toEqual(['a cat', '+']); + + await render(krea); + expect(host.querySelector('pre')).toBeNull(); + expect(await weightCat()).toBe(false); + expect(textarea().value).toBe('a cat+'); + + await render(sdxl); + expect(colouredSegments()).toEqual(['a cat', '+']); + expect(await weightCat()).toBe(true); + await expect.poll(() => textarea().value).toBe('a cat++'); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.tsx index 2954de35b99..d53aff50b5c 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PositivePromptField.tsx @@ -4,6 +4,7 @@ import type { ExpandPromptSuggestion, GenerateLora, GenerateModelConfig } from ' import type { ChangeEvent, KeyboardEvent } from 'react'; import { Box, Text } from '@chakra-ui/react'; +import { supportsPromptAttentionSyntax } from '@features/generation/core/prompt/compel'; import { applyPromptTemplate, getPromptTemplateChunks } from '@features/generation/core/promptTemplates'; import { useRegisterGenerateDraftFlusher } from '@features/generation/ui/generateDraftRegistry'; import { useDebouncedDraftValue } from '@features/generation/ui/useDebouncedDraftValue'; @@ -20,7 +21,6 @@ import { type PromptTemplateState, type SavedPromptModels, } from './PositivePromptActions'; -import { PROMPT_ATTENTION_TARGET_PROPS } from './promptAttentionHotkeys'; import { insertPromptText, registerPositivePromptElement } from './promptFocus'; import { promptHistoryNavigation } from './promptHistoryNavigation'; import { PromptTextarea } from './PromptTextarea'; @@ -84,6 +84,7 @@ export const PositivePromptField = ({ const { t } = useTranslation(); const textareaRef = useRef(null); const { knownNames: knownWildcards } = useWildcards(); + const readsAttentionSyntax = supportsPromptAttentionSyntax(selectedModel?.base); const { commitDraftValue, draftValue, flushDraftValue, replaceDraftValue, setDraftValue } = useDebouncedDraftValue({ delayMs: PROMPT_INPUT_DEBOUNCE_MS, onCommit: onChange, @@ -201,6 +202,7 @@ export const PositivePromptField = ({ const labelEnd = useMemo( () => ( { + attentionSyntax?: PromptAttentionSyntax; /** Enable dynamic highlighting only for prompts that actually expand syntax. */ highlightDynamicPrompts?: boolean; /** Resolvable wildcard names; an unknown `__name__` is underlined as an error. */ @@ -50,6 +59,7 @@ const setRef = (ref: Ref | undefined, element: HTMLTextArea }; export const PromptTextarea = ({ + attentionSyntax = 'highlight', fontFamily = 'mono', fontSize, highlightDynamicPrompts = false, @@ -67,8 +77,9 @@ export const PromptTextarea = ({ const [scroll, setScroll] = useState({ left: 0, top: 0 }); const [textareaClientWidth, setTextareaClientWidth] = useState(null); const isWithinHighlightBudget = value.length > 0 && value.length <= MAX_HIGHLIGHTED_PROMPT_LENGTH; + const highlightsSyntax = showSyntaxHighlighting && (attentionSyntax !== 'none' || highlightDynamicPrompts); // Template chunk emphasis is independent of the syntax-highlighting preference. - const shouldHighlight = (showSyntaxHighlighting || templateChunks !== null) && isWithinHighlightBudget; + const shouldHighlight = (highlightsSyntax || templateChunks !== null) && isWithinHighlightBudget; const effectiveFontSize = fontSize ?? PROMPT_FONT_SIZE; const effectiveLineHeight = lineHeight ?? PROMPT_TEXTAREA_LINE_HEIGHT; // The gutter widens with the line count, and the text has to start clear of it. @@ -76,8 +87,8 @@ export const PromptTextarea = ({ const paddingInlineStart = showLineNumbers ? `calc(${PROMPT_TEXTAREA_PX} + ${gutterCh}ch)` : undefined; const highlightOptions = useMemo( - () => ({ dynamicPrompts: highlightDynamicPrompts, knownWildcards }), - [highlightDynamicPrompts, knownWildcards] + () => ({ attentionSyntax: attentionSyntax !== 'none', dynamicPrompts: highlightDynamicPrompts, knownWildcards }), + [attentionSyntax, highlightDynamicPrompts, knownWildcards] ); const needsMirror = shouldHighlight || showLineNumbers; @@ -170,7 +181,7 @@ export const PromptTextarea = ({ {templateChunks[0]} - {showSyntaxHighlighting ? ( + {highlightsSyntax ? ( ) : ( templateChunks[1] @@ -191,11 +202,11 @@ export const PromptTextarea = ({ effectiveLineHeight, fontFamily, highlightOptions, + highlightsSyntax, paddingInlineStart, scroll.left, scroll.top, shouldHighlight, - showSyntaxHighlighting, templateChunks, textareaClientWidth, value, @@ -216,6 +227,7 @@ export const PromptTextarea = ({ return ( { return ( { it('leaves a read-only prompt alone', async () => { const ReadOnlyHarness = () => ( { expect(ran).toEqual(['gallery.remix', 'viewer.remix']); }); }); + +describe('WorkbenchHotkeyRuntime and prompt weighting', () => { + let promptRoot: Root | null = null; + + beforeEach(() => { + // Registered without a source, as `useRegisterFirstPartyCommands` does. + runtimeMocks.extensions.commands.register({ + handler: () => adjustFocusedPromptAttention('increment', false), + id: 'app.promptWeightUp', + title: 'app.promptWeightUp', + }); + }); + + afterEach(async () => { + await act(() => promptRoot?.unmount()); + promptRoot = null; + }); + + const PromptHarness = ({ attentionSyntax }: { attentionSyntax: 'edit' | 'none' }) => { + const [value, setValue] = useState('a cat'); + const handleChange = useCallback( + (event: ChangeEvent) => setValue(event.currentTarget.value), + [] + ); + + return ( + + ); + }; + + /** Select "cat" and press Ctrl+Up; report whether the runtime took the key from the text field. */ + const pressWeightUp = async (attentionSyntax: 'edit' | 'none') => { + const promptHost = document.createElement('div'); + host!.append(promptHost); + promptRoot = createRoot(promptHost); + await act(() => { + promptRoot?.render( + + + + ); + }); + const textarea = promptHost.querySelector('textarea')!; + textarea.focus(); + textarea.setSelectionRange(2, 5); + + let claimed = false; + await act(async () => { + claimed = !textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'ArrowUp', + ctrlKey: true, + key: 'ArrowUp', + }) + ); + await Promise.resolve(); + }); + + return { claimed, textarea }; + }; + + it('weights the selection in a prompt whose model reads attention syntax', async () => { + const { claimed, textarea } = await pressWeightUp('edit'); + + expect(claimed).toBe(true); + await expect.poll(() => textarea.value).toBe('a cat+'); + }); + + it('leaves Ctrl+Up to the text field when the model reads the prompt as plain text', async () => { + const { claimed, textarea } = await pressWeightUp('none'); + + expect(claimed).toBe(false); + expect(textarea.value).toBe('a cat'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index 25d8ae1c113..04c361ecf44 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -171,6 +171,12 @@ export const OPEN_COMMAND_PALETTE_HOTKEY: HotkeyDefinition = { title: 'Open Command Palette', }; +/** + * Generation's `PromptTextarea` marks an editable prompt this way only while the selected model reads attention + * syntax. Anywhere else Ctrl+Up/Down keeps its text-field meaning. + */ +const PROMPT_ATTENTION_TARGET_SELECTOR = 'textarea[data-prompt-attention-target="true"]:not([readonly])'; + export type RegionFocusDirection = 'down' | 'left' | 'right' | 'up'; /** @@ -231,8 +237,8 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('app', 'selectQueueTab', ['6']), hotkey('app', 'promptHistoryPrev', ['alt+arrowup']), hotkey('app', 'promptHistoryNext', ['alt+arrowdown']), - hotkey('app', 'promptWeightUp', ['ctrl+arrowup']), - hotkey('app', 'promptWeightDown', ['ctrl+arrowdown']), + { ...hotkey('app', 'promptWeightUp', ['ctrl+arrowup']), targetSelector: PROMPT_ATTENTION_TARGET_SELECTOR }, + { ...hotkey('app', 'promptWeightDown', ['ctrl+arrowdown']), targetSelector: PROMPT_ATTENTION_TARGET_SELECTOR }, hotkey('app', 'focusPrompt', ['alt+a']), hotkey('app', 'toggleLeftPanel', ['t', 'o']), hotkey('app', 'toggleRightPanel', ['g']), diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.browser.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.browser.test.ts new file mode 100644 index 00000000000..1acbd392eb3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.browser.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { HotkeyContext, RegisteredHotkey } from './types'; + +import { firstPartyHotkeyCatalog } from './catalog'; +import { applyCustomHotkeys, resolveHotkey } from './resolve'; + +/** Target selectors match real elements, so this runs where the DOM does. */ +describe('resolveHotkey with target-scoped bindings', () => { + const host = document.createElement('div'); + + document.body.append(host); + + afterEach(() => { + host.replaceChildren(); + }); + + const context: HotkeyContext = { + activeInstanceId: null, + activeWidgetTypeId: null, + focusedRegion: null, + isModalPresent: false, + projectId: 'project-1', + }; + const weightUp = applyCustomHotkeys( + firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === 'app.promptWeightUp')!, + {} + ); + // A lower-priority global binding on the same chord, as a user customization could add. + const fallback: RegisteredHotkey = { + allowInEditable: true, + category: 'app', + commandId: 'fallback', + defaultKeys: ['ctrl+arrowup'], + id: 'fallback', + implemented: true, + keys: ['ctrl+arrowup'], + scope: { kind: 'global' }, + title: 'Fallback', + }; + + const resolveOn = (html: string, hotkeys: RegisteredHotkey[]) => { + host.innerHTML = html; + const target = host.querySelector('[data-target]'); + + return ( + resolveHotkey({ context, event: { target } as unknown as KeyboardEvent, hotkeys, matchedKey: 'ctrl+arrowup' }) + ?.commandId ?? null + ); + }; + + it('claims the chord on a prompt that takes attention weighting', () => { + expect(resolveOn('', [weightUp])).toBe( + 'app.promptWeightUp' + ); + }); + + it('claims nothing on any other text field, so the key keeps its native meaning', () => { + expect(resolveOn('', [weightUp])).toBeNull(); + expect(resolveOn('', [weightUp])).toBeNull(); + }); + + it('leaves a read-only prompt unclaimed', () => { + expect( + resolveOn('', [weightUp]) + ).toBeNull(); + }); + + it('lets the next candidate win where the target does not match', () => { + expect(resolveOn('', [weightUp, fallback])).toBe('fallback'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts index 89d1e81e462..7680e388fd9 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts @@ -41,6 +41,10 @@ const getScopePriority = (hotkey: RegisteredHotkey, context: HotkeyContext): num return 100; }; +const isClaimedTarget = (hotkey: RegisteredHotkey, target: EventTarget | null): boolean => + !hotkey.targetSelector || + (typeof Element !== 'undefined' && target instanceof Element && target.matches(hotkey.targetSelector)); + export const applyCustomHotkeys = ( hotkey: Hotkey, customHotkeys: Record @@ -67,6 +71,7 @@ export const resolveHotkey = ({ hotkeys .filter((hotkey) => hotkey.implemented !== false && hotkey.keys.some((key) => toPlatformHotkey(key) === pressed)) .filter((hotkey) => hotkey.allowInEditable || !isEditable) + .filter((hotkey) => isClaimedTarget(hotkey, event.target)) .filter((hotkey) => hotkey.allowInModal || !context.isModalPresent) .map((hotkey) => ({ hotkey, priority: getScopePriority(hotkey, context) })) .filter(({ priority }) => priority >= 0) diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts index 98371bb3099..9d9db37a475 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/types.ts @@ -25,6 +25,8 @@ export interface HotkeyDefinition { preventDefault?: boolean; allowInEditable?: boolean; allowInModal?: boolean; + /** Claim the key only while the press targets an element matching this selector; elsewhere it keeps its meaning. */ + targetSelector?: string; unavailableReason?: string; implemented?: boolean; source?: WidgetContributionSource; diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/README.md b/invokeai/frontend/webv2/src/workbench/widget-frame/README.md index b9ca9b03a8f..dc4cce2b45c 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/README.md +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/README.md @@ -38,7 +38,7 @@ Workbench focus is transient, never persisted. `WorkbenchFocusProvider` (`workbe - **Recall is the one reveal that moves no focus.** Recall raises and expands its window but leaves keyboard focus, outline, and hotkey target where user had them; next press or focus change brings them back into agreement. Since focus inside active window does not re-raise, it cannot re-bury the window a recall just revealed. - **Keys follow the outline.** A press inside a window moves keyboard focus into it when focus was elsewhere; a press on a docked region pulls focus out of a floating window. Floating layer renders windows in fixed DOM order and stacks them by z-index alone: raising a window never moves its node, which would drop the focus that raised it. - **Focus follows placement:** into window after Float and after a marker or "Show window" reveal; into the region a docked rail tab shows in when it is selected, so keys stop going to a window that held them; into return region after Dock; after Remove from a marker's menu, into center when it gets its view back, else into the panel the window's own rail is showing, or center when that rail shows none (emptied by float or collapsed). Removing a window from a rail's enable menu moves no focus: that menu stays open and keeps it. A focus move targets region frame on screen (side regions keep earlier panels mounted but hidden), waits a bounded time for its widget to mount, and is abandoned if user presses elsewhere first. Every panel frame carries its instance's identity, including stand-in for a widget whose view cannot render, so a move into it lands. -- **Hotkeys read the same target.** `HotkeyContext.focusedRegion` is `WidgetRegion | 'floating' | null`; persisted `WidgetRegion` unchanged. A key press is aimed at widget under event target first and, outside any widget's DOM, at active floating instance or focused region's active member (`resolveHotkeyTarget`). A region's active pointer counts only while it names a member, so a view that floated out of an emptied center answers as a window, not as center. Floating command source carries `region: 'floating'` and is never used to index `widgetRegions`. Generic `focused-region` shortcuts match under a window, shortcuts tied to a named docked region do not, and a floating widget's own `focused-region` contribution follows its window at the same priority it has when docked. +- **Hotkeys read the same target.** `HotkeyContext.focusedRegion` is `WidgetRegion | 'floating' | null`; persisted `WidgetRegion` unchanged. A key press is aimed at widget under event target first and, outside any widget's DOM, at active floating instance or focused region's active member (`resolveHotkeyTarget`). A region's active pointer counts only while it names a member, so a view that floated out of an emptied center answers as a window, not as center. Floating command source carries `region: 'floating'` and is never used to index `widgetRegions`. Generic `focused-region` shortcuts match under a window, shortcuts tied to a named docked region do not, and a floating widget's own `focused-region` contribution follows its window at the same priority it has when docked. A binding with a `targetSelector` (prompt weighting: an editable prompt whose model reads attention syntax) is a candidate only while the key press's target matches it; elsewhere the next candidate wins, or nothing claims the key and it keeps its browser behavior. - **Modal dialogs suspend application shortcuts.** Every dialog is built on Platform `Dialog` root (`platform/ui/Dialog.tsx`; lint rejects Chakra's dialog and its drawer, which has no such root yet), which registers with `platform/ui/modalPresence` while its own open state holds, unless declared `modal={false}`. A dialog whose module is still loading uses `Dialog.Pending` as suspense fallback, so it is modal from keypress that opened it. While any registration stands, hotkey runtime runs only hotkeys marked `allowInModal` (command palette toggle), so invoke, global and widget shortcuts stay inactive wherever focus is, and keys the dialog handles itself (Enter, Escape, typing) keep browser behavior; Canvas engine's own key listener takes no key either. Both ask about the key press they are handling: a press that began under a modal still counts as aimed at it, because the Escape that dismisses a dialog releases its presence before the same press reaches bubble-phase listeners. Presence follows interactivity, not DOM: a closing dialog that stays mounted through its exit animation is inert and releases at close, so next press reaches application; reopening re-registers at once, an outer dialog stays registered while an inner one closes, and an unmount releases without a close. Media paste reads the same presence. Popovers and menus do not register. ## Contextual shortcut guidance diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.browser.test.tsx index bd6389fd1d7..f47db5d28fd 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.browser.test.tsx @@ -83,6 +83,17 @@ describe('RegionalGuidanceSettings per model base', () => { setArchitectureCapabilities(architectureCapabilitiesFixture); }); + it.each([ + ['sdxl', true], + ['flux', false], + ])('colours and weights regional attention syntax on %s: %s', async (base, readsAttention) => { + await render(base, createLayer({ positivePrompt: 'a cat+' })); + const prompt = host!.querySelector('[aria-label="widgets.layers.regionalGuidance.positivePrompt"]')!; + + expect(host!.querySelector('pre') !== null).toBe(readsAttention); + expect(prompt.matches('[data-prompt-attention-target="true"]')).toBe(readsAttention); + }); + it('offers both prompt polarities on an SD base', async () => { await render('sd-1', createLayer()); expect(alerts()).toEqual([]); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx index 9aebabf3dfd..a4a548765e2 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx @@ -9,8 +9,8 @@ import type { CanvasStructuralEngine } from '@workbench/widgets/layers/layerOps' import type { ChangeEvent, FocusEvent } from 'react'; import { createListCollection, HStack, IconButton, Stack, Switch, Text } from '@chakra-ui/react'; -import { PROMPT_ATTENTION_TARGET_PROPS, PromptTextarea } from '@features/generation/components'; -import { getRegionalGuidanceSupport } from '@features/generation/graph'; +import { PromptTextarea } from '@features/generation/components'; +import { getRegionalGuidanceSupport, supportsPromptAttentionSyntax } from '@features/generation/graph'; import { getArchitectureCapabilitiesSnapshot, subscribeArchitectureCapabilities } from '@features/generation/runtime'; import { useExternalStoreSelector } from '@platform/state/selectors'; import { Button, ColorPicker, Field, Select, Tooltip } from '@platform/ui'; @@ -72,6 +72,7 @@ export const RegionalGuidanceSettings = ({ engine, layer }: RegionalGuidanceSett const [positivePrompt, setPositivePrompt] = useState(layer.positivePrompt ?? ''); const [negativePrompt, setNegativePrompt] = useState(layer.negativePrompt ?? ''); + const attentionSyntax = supportsPromptAttentionSyntax(base) ? 'edit' : 'none'; const fill = layer.mask.fill; // Resolve support inside the capability selector to update memoized answers. Distinguish unavailable policy from // unsupported models. @@ -199,8 +200,8 @@ export const RegionalGuidanceSettings = ({ engine, layer }: RegionalGuidanceSett ) : null} Date: Fri, 16 Oct 2026 00:40:12 -0600 Subject: [PATCH 5/8] fix(hints): restore informational popovers on scrubber inputs Add legacy hint copy to denoising strength, LoRA, control and regional reference weights, Krea-2 controls and step ranges. Open the card beside the panel and keep it open while the pointer moves from the label to the card. Drop the aspect-ratio hint and the denoising copy misplaced on the FLUX Redux select. --- .../performance/architecture-baseline.json | 2 + .../webv2/performance/browser-baseline.json | 24 ++- .../frontend/webv2/public/locales/en.json | 75 +++++-- .../ui/GenerateConditioningRebalanceField.tsx | 1 + .../generation/ui/GenerateRenderSection.tsx | 3 + .../ReferenceImageControls.tsx | 4 +- .../generation/ui/shared/ConceptRow.tsx | 1 + .../src/platform/ui/Field.browser.test.tsx | 11 + .../frontend/webv2/src/platform/ui/Field.tsx | 7 +- .../ui/ScrubberField.browser.test.tsx | 99 +++++++++ .../webv2/src/platform/ui/ScrubberField.tsx | 4 +- .../ui/hints/FeatureHint.browser.test.tsx | 177 ++++++++++++++-- .../src/platform/ui/hints/FeatureHint.tsx | 200 +++++++++++++++++- .../platform/ui/hints/featureHint.testing.ts | 81 +++++++ .../src/platform/ui/hints/hintGeometry.ts | 79 +++++++ .../src/platform/ui/hints/hintRegistry.ts | 18 +- .../webv2/src/platform/ui/hints/index.ts | 1 + .../canvas/GenerateDenoisingStrength.tsx | 1 + .../widgets/layers/ControlLayerSettings.tsx | 3 +- .../widgets/layers/ReferenceImageSettings.tsx | 1 + 20 files changed, 728 insertions(+), 64 deletions(-) create mode 100644 invokeai/frontend/webv2/src/platform/ui/hints/featureHint.testing.ts create mode 100644 invokeai/frontend/webv2/src/platform/ui/hints/hintGeometry.ts diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 2b757f140f0..d3378d11d25 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -251,6 +251,7 @@ "source:src/platform/ui/colorPickerStore.ts", "source:src/platform/ui/colorPickerSync.ts", "source:src/platform/ui/hints/FeatureHint.tsx", + "source:src/platform/ui/hints/hintGeometry.ts", "source:src/platform/ui/hints/hintRegistry.ts", "source:src/platform/ui/hints/hintsContext.tsx", "source:src/platform/ui/hints/index.ts", @@ -787,6 +788,7 @@ "source:src/platform/ui/colorPickerStore.ts", "source:src/platform/ui/colorPickerSync.ts", "source:src/platform/ui/hints/FeatureHint.tsx", + "source:src/platform/ui/hints/hintGeometry.ts", "source:src/platform/ui/hints/hintRegistry.ts", "source:src/platform/ui/hints/hintsContext.tsx", "source:src/platform/ui/hints/index.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 3488f894710..b69962296ef 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -30,7 +30,7 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 30, "scriptRawBytes": 1842561, "scriptRequestCount": 26, @@ -68,7 +68,7 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 30, "scriptRawBytes": 1842561, "scriptRequestCount": 26, @@ -106,7 +106,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 88, "scriptRawBytes": 4063609, "scriptRequestCount": 83, @@ -144,7 +144,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 88, "scriptRawBytes": 4063609, "scriptRequestCount": 83, @@ -182,7 +182,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 97, "scriptRawBytes": 4578127, "scriptRequestCount": 92, @@ -220,7 +220,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 97, "scriptRawBytes": 4578127, "scriptRequestCount": 92, @@ -258,7 +258,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 95, "scriptRawBytes": 4484528, "scriptRequestCount": 89, @@ -296,7 +296,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 95, "scriptRawBytes": 4484528, "scriptRequestCount": 89, @@ -334,7 +334,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 88, "scriptRawBytes": 4063609, "scriptRequestCount": 83, @@ -372,7 +372,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 749029, - "otherRawBytes": 200480, + "otherRawBytes": 205278, "requestCount": 88, "scriptRawBytes": 4063609, "scriptRequestCount": 83, @@ -662,6 +662,7 @@ "source:src/platform/ui/colorPickerStore.ts", "source:src/platform/ui/colorPickerSync.ts", "source:src/platform/ui/hints/FeatureHint.tsx", + "source:src/platform/ui/hints/hintGeometry.ts", "source:src/platform/ui/hints/hintRegistry.ts", "source:src/platform/ui/hints/hintsContext.tsx", "source:src/platform/ui/hints/index.ts", @@ -1330,6 +1331,7 @@ "source:src/platform/ui/colorPickerStore.ts", "source:src/platform/ui/colorPickerSync.ts", "source:src/platform/ui/hints/FeatureHint.tsx", + "source:src/platform/ui/hints/hintGeometry.ts", "source:src/platform/ui/hints/hintRegistry.ts", "source:src/platform/ui/hints/hintsContext.tsx", "source:src/platform/ui/hints/index.ts", @@ -2425,6 +2427,7 @@ "source:src/platform/ui/colorPickerStore.ts", "source:src/platform/ui/colorPickerSync.ts", "source:src/platform/ui/hints/FeatureHint.tsx", + "source:src/platform/ui/hints/hintGeometry.ts", "source:src/platform/ui/hints/hintRegistry.ts", "source:src/platform/ui/hints/hintsContext.tsx", "source:src/platform/ui/hints/index.ts", @@ -3736,6 +3739,7 @@ "source:src/platform/ui/colorPickerStore.ts", "source:src/platform/ui/colorPickerSync.ts", "source:src/platform/ui/hints/FeatureHint.tsx", + "source:src/platform/ui/hints/hintGeometry.ts", "source:src/platform/ui/hints/hintRegistry.ts", "source:src/platform/ui/hints/hintsContext.tsx", "source:src/platform/ui/hints/index.ts", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 4e40c2b4ed6..b5ebaa9bcb7 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -798,13 +798,6 @@ "title": "Hotkeys" }, "hints": { - "aspectRatio": { - "heading": "Aspect Ratio", - "paragraphs": [ - "The aspect ratio of the dimensions of the image generated.", - "An image size (in number of pixels) equivalent to 512x512 is recommended for SD1.5 models and a size equivalent to 1024x1024 is recommended for SDXL models." - ] - }, "cfgRescale": { "heading": "CFG Rescale Multiplier", "paragraphs": [ @@ -842,12 +835,35 @@ "Gain scales the whole stack afterwards, which counteracts the quality dilution from distillation. Leave both at the defaults unless prompt adherence is the problem you are solving." ] }, + "conditioningRebalanceGain": { + "heading": "Rebalance gain", + "paragraphs": [ + "Overall gain applied to the conditioning after per-layer weighting. Higher values push harder toward the prompt.", + "Default is 4.0. Very high values may over-saturate or distort the output." + ] + }, + "controlLayerWeight": { + "heading": "Control Layer Weight", + "paragraphs": [ + "Adjusts how strongly the layer influences the generation process.", + "• Higher Weight (.75-2): Creates a more significant impact on the final result.", + "• Lower Weight (below .75): Creates a smaller impact on the final result. The slider stops at 0; models that allow it accept typed weights down to -1." + ] + }, "creativity": { "heading": "Creativity", "paragraphs": [ "Creativity controls the amount of freedom granted to the model when adding details. Low creativity stays close to the original image, while high creativity allows for more change. When using a prompt, high creativity increases the influence of the prompt." ] }, + "denoisingStrength": { + "heading": "Denoising Strength", + "paragraphs": [ + "Controls how much the generated image varies from the raster layers.", + "Lower strength stays closer to the combined visible raster layers. Higher strength relies more on the global prompt.", + "When there are no raster layers with visible content, this setting is ignored." + ] + }, "guidance": { "heading": "Guidance", "paragraphs": [ @@ -859,14 +875,6 @@ "heading": "Height", "paragraphs": ["Height of the generated image. Must be a multiple of 8."] }, - "imageInfluence": { - "heading": "Denoising Strength", - "paragraphs": [ - "Controls how much the generated image varies from the raster layers.", - "Lower strength stays closer to the combined visible raster layers. Higher strength relies more on the global prompt.", - "When there are no raster layers with visible content, this setting is ignored." - ] - }, "hidiffusion": { "heading": "HiDiffusion", "paragraphs": [ @@ -902,6 +910,27 @@ "Can boost local detail, but may affect global coherence." ] }, + "krea2SeedVariance": { + "heading": "Seed variance", + "paragraphs": [ + "Distilled few-step models like Krea-2-Turbo can produce nearly identical images across different seeds. This adds seed-based noise to the text embeddings to increase variation while staying reproducible.", + "Enable this to get more diverse results when exploring seeds, at some cost to prompt adherence." + ] + }, + "krea2SeedVarianceRandomize": { + "heading": "Randomized channels", + "paragraphs": [ + "Percentage of embedding values that receive noise (0-100%); 0 disables.", + "Lower values create more selective perturbations, while 100% affects all values." + ] + }, + "krea2SeedVarianceStrength": { + "heading": "Variance strength", + "paragraphs": [ + "Noise strength as a multiplier of the embedding's standard deviation, so it behaves consistently regardless of the embedding scale (including whether conditioning rebalance is on).", + "0 is off, 0.1 is subtle, 0.5 is strong. Higher values give more variety but drift further from the prompt." + ] + }, "layerStackControl": { "heading": "Control Layers", "paragraphs": [ @@ -930,6 +959,10 @@ "Painted regions scope their prompts and reference images to just that area of the canvas." ] }, + "loraWeight": { + "heading": "LoRA Weight", + "paragraphs": ["Weight of the LoRA. Higher weight will lead to larger impacts on the final image."] + }, "model": { "heading": "Model", "paragraphs": [ @@ -940,7 +973,7 @@ "heading": "Negative Prompt", "paragraphs": [ "The generation process avoids the concepts in the negative prompt. Use this to exclude qualities or objects from the output.", - "Supports Compel syntax and embeddings." + "Compel attention weighting and embeddings work with SD 1.5, SD 2.x and SDXL only." ] }, "pidMode": { @@ -956,7 +989,7 @@ "heading": "Positive Prompt", "paragraphs": [ "Guides the generation process. You may use any words or phrases.", - "Compel and Dynamic Prompts syntaxes and embeddings." + "Dynamic Prompts syntax works with every model. Compel attention weighting and embeddings work with SD 1.5, SD 2.x and SDXL only." ] }, "qwen35Encoder": { @@ -1003,6 +1036,14 @@ "Disable the \"Random\" option to produce identical results with the same generation settings." ] }, + "stepRange": { + "heading": "Begin / End Step Percentage", + "paragraphs": [ + "This setting determines which portion of the denoising (generation) process incorporates this guidance.", + "• Start Step (%): Specifies when to begin applying this guidance during the generation process.", + "• End Step (%): Specifies when to stop applying this guidance and revert to general guidance from the model and other settings." + ] + }, "steps": { "heading": "Steps", "paragraphs": [ diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx index 90db303b91a..34b69e5eb2c 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx @@ -304,6 +304,7 @@ export const GenerateConditioningRebalanceField = ({ > { <> { {settings.krea2SeedVarianceEnabled ? ( <> { /> ) : null} - + {formatPct(beginEndStepPct[0])} – {formatPct(beginEndStepPct[1])} @@ -366,7 +366,7 @@ export const FluxReduxControls = + + + ); + + await act(() => { + root?.render( + + + + {field ? ( + + {content} + + ) : ( + content + )} + + + + ); + }); + + return { + control: host.querySelector('input')!, + panel: host, + trigger: host.querySelector('[data-part="trigger"]')!, + triggers: [...host.querySelectorAll('[data-part="trigger"]')], + }; +}; + describe('FeatureHint', () => { it('renders the child untouched and mounts no popper while hints are off', async () => { const trigger = await render({ enabled: false, onDisable: vi.fn() }); @@ -170,4 +213,108 @@ describe('FeatureHint', () => { expect(cardText()).toContain('CLIP Skip'); expect(cardText()).not.toContain("Don't show me these"); }); + + describe('placement', () => { + it('sits beside its panel, level with the label, instead of over the controls in it', async () => { + const { panel, trigger } = await renderHint(`${PANEL_CSS};width:80px;left:8px`); + const card = await openHintCard(trigger); + const label = trigger.getBoundingClientRect(); + + expect(card.left).toBeGreaterThanOrEqual(panel.getBoundingClientRect().right); + expect(card.top).toBeLessThan(label.bottom); + expect(card.bottom).toBeGreaterThan(label.top); + }); + + it('flips to the other side of the panel when the viewport edge leaves no room', async () => { + const { panel, trigger } = await renderHint(`${PANEL_CSS};width:80px;right:8px`); + const card = await openHintCard(trigger); + + expect(card.right).toBeLessThanOrEqual(panel.getBoundingClientRect().left); + }); + + it('sits beside the field, on screen, when the panel fills the viewport', async () => { + // A maximized floating window: no room beside the panel on either side. + const { control, trigger } = await renderHint(`${PANEL_CSS};left:0;width:100vw`, { field: true }); + const card = await openHintCard(trigger); + + expect(card.left).toBeGreaterThanOrEqual(control.getBoundingClientRect().right); + expect(card.right).toBeLessThanOrEqual(document.documentElement.clientWidth); + }); + + it('sits beside the marked field when no panel scrolls around it', async () => { + const { control, trigger } = await renderHint('position:fixed;top:120px;left:8px', { field: true }); + const card = await openHintCard(trigger); + + expect(card.left).toBeGreaterThanOrEqual(control.getBoundingClientRect().right); + }); + }); + + describe('on the way to the card', () => { + const LEFT_PANEL_CSS = `${PANEL_CSS};width:80px;left:8px`; + const MOVE = { stepDelayMs: 20, steps: 4 }; + + it('stays open while the pointer travels from the label to the link in the card', async () => { + const { trigger } = await renderHint(LEFT_PANEL_CSS); + const card = await openHintCard(trigger); + const link = [...document.querySelectorAll('a')].find((anchor) => anchor.textContent === 'Learn more')!; + const label = trigger.getBoundingClientRect(); + const to = link.getBoundingClientRect(); + const exit = { x: label.right - 2, y: label.top + label.height / 2 }; + // Short of the card and clear of its arrow, which points at the label and counts as the card. + const pause = { x: card.left - 4, y: exit.y + 24 }; + + // A pause that outlasts the close delay: only the corridor keeps the card open through it. + await movePointerAlong(exit, pause, MOVE); + await settle(400); + await movePointerAlong(pause, { x: to.left + to.width / 2, y: to.top + to.height / 2 }, MOVE); + await settle(500); + + expect(cardText()).toContain('Learn more'); + expect(document.elementFromPoint(to.left + to.width / 2, to.top + to.height / 2)?.closest('a')).toBe(link); + }); + + it('closes once the pointer leaves the corridor sideways', async () => { + const { trigger } = await renderHint(LEFT_PANEL_CSS); + + await openHintCard(trigger); + + const from = trigger.getBoundingClientRect(); + + // Down the panel, away from the card beside it. + await movePointerAlong( + { x: from.left + from.width / 2, y: from.bottom - 2 }, + { x: from.left + from.width / 2, y: from.bottom + 80 }, + MOVE + ); + await settle(600); + + expect(cardText()).toBe(''); + }); + + it('closes after a grace window when the pointer rests on the way', async () => { + const { trigger } = await renderHint(LEFT_PANEL_CSS); + const card = await openHintCard(trigger); + const label = trigger.getBoundingClientRect(); + const exit = { x: label.right - 2, y: label.top + label.height / 2 }; + + await movePointerAlong(exit, { x: card.left - 4, y: exit.y + 24 }, MOVE); + // Past the grace window and the close delay after it. + await settle(1700); + + expect(cardText()).toBe(''); + }); + + it('hands over to another hinted label on the way, so only one card is open', async () => { + const { + triggers: [first, second], + } = await renderHint(LEFT_PANEL_CSS, { labels: ['CLIP', 'Skip'] }); + + await openHintCard(first!); + await act(() => userEvent.hover(second!)); + await expect.poll(() => second!.getAttribute('data-state'), { timeout: 3000 }).toBe('open'); + + expect(document.querySelectorAll('[data-scope="hover-card"][data-part="content"]')).toHaveLength(1); + expect(first!.getAttribute('data-state')).toBe('closed'); + }); + }); }); diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx b/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx index cd7399b6e71..42fd271f699 100644 --- a/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes, ReactElement, ReactNode } from 'react'; +import type { HTMLAttributes, PointerEvent, ReactElement, ReactNode } from 'react'; import { Heading, @@ -14,18 +14,31 @@ import { Text, useHoverCardContext, } from '@chakra-ui/react'; +import { useMountEffect } from '@platform/react/useMountEffect'; import { Button } from '@platform/ui/Button'; import { ExternalLinkIcon } from 'lucide-react'; -import { cloneElement, createContext, useContext, useMemo } from 'react'; +import { cloneElement, createContext, useCallback, useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { + createGraceCorridor, + findScrollingPanel, + getHintAnchorRect, + HINT_GUTTER_PX, + HINT_VIEWPORT_PADDING_PX, +} from './hintGeometry'; import { DEFAULT_HINT_PLACEMENT, getFeatureHint, type FeatureHintId } from './hintRegistry'; import { useFeatureHints } from './hintsContext'; +type HintPositioning = NonNullable; + /** Long enough that hints stay out of the way while a user works a panel. */ const OPEN_DELAY = 600; -/** Long enough to let the pointer travel from the label into the card's links. */ +/** Forgives a brief slip off the label or card; the grace corridor covers the trip from label to card. */ const CLOSE_DELAY = 300; +/** How long the corridor waits for progress toward the card; resting on the way, or on the field's control, ends it. */ +const CORRIDOR_GRACE_MS = 1000; +const HINT_TRIGGER_SELECTOR = '[data-scope="hover-card"][data-part="trigger"]'; const readParagraphs = (value: unknown): string[] => Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; @@ -95,6 +108,146 @@ export const FeatureHintExclusion = ({ children }: { children: ReactElement { + let card: HTMLElement | null = null; + let panel: { panel: HTMLElement | null; trigger: Element } | null = null; + let corridor: AbortController | null = null; + + const cancelCorridor = () => corridor?.abort(); + + return { + cancelCorridor, + getAnchorRect: (anchor: unknown) => { + if (!(anchor instanceof HTMLElement)) { + return null; + } + + if (panel?.trigger !== anchor) { + panel = { panel: findScrollingPanel(anchor), trigger: anchor }; + } + + return getHintAnchorRect(anchor, panel.panel, card); + }, + resetPanel: () => { + panel = null; + }, + setCard: (element: HTMLElement | null) => { + card = element; + }, + /** + * Leaving the label toward an open card keeps it open while the pointer travels the corridor to it, as long as it + * gets closer within each `CORRIDOR_GRACE_MS`. Leaving the corridor, another hint's label, a scroll, or the pointer leaving the + * page calls `close`, as leaving the label would have. Returns false when no corridor applies. + */ + startCorridor: (exit: Point, trigger: HTMLElement, close: () => void): boolean => { + cancelCorridor(); + + const cardRect = card?.getBoundingClientRect(); + const isInCorridor = cardRect ? createGraceCorridor(exit, trigger.getBoundingClientRect(), cardRect) : null; + + if (!card || !cardRect || !isInCorridor) { + return false; + } + + const openCard = card; + const session = new AbortController(); + const listen = { capture: true, signal: session.signal }; + // Anything but steady travel to the card gives the card back to the ordinary close delay. + const giveUp = () => { + session.abort(); + close(); + }; + const isOverCard = (x: number, y: number) => + x >= cardRect.left && x <= cardRect.right && y >= cardRect.top && y <= cardRect.bottom; + const distanceToCard = (x: number) => Math.min(Math.abs(x - cardRect.left), Math.abs(x - cardRect.right)); + let closest = distanceToCard(exit.x); + let graceTimer = globalThis.setTimeout(giveUp, CORRIDOR_GRACE_MS); + + corridor = session; + session.signal.addEventListener('abort', () => globalThis.clearTimeout(graceTimer)); + document.addEventListener( + 'pointermove', + ({ clientX: x, clientY: y }) => { + // Over the card, or closed some other way: the hover card owns what happens next. + if (!openCard.isConnected || isOverCard(x, y)) { + session.abort(); + } else if (!isInCorridor({ x, y })) { + giveUp(); + } else if (distanceToCard(x) < closest) { + // Progress toward the card renews the grace window; resting or wandering lets it run out. + closest = distanceToCard(x); + globalThis.clearTimeout(graceTimer); + graceTimer = globalThis.setTimeout(giveUp, CORRIDOR_GRACE_MS); + } + }, + listen + ); + // Another hint's label takes over, so only one card is ever open. + document.addEventListener( + 'pointerover', + ({ target }) => { + const otherTrigger = target instanceof Element ? target.closest(HINT_TRIGGER_SELECTOR) : null; + + if (otherTrigger && otherTrigger !== trigger) { + giveUp(); + } + }, + listen + ); + // The card's rect is fixed for the trip; a scroll moves it. + window.addEventListener('scroll', giveUp, listen); + window.addEventListener('blur', giveUp, listen); + // Not in capture: there, every descendant's pointerleave would pass through. + document.documentElement.addEventListener('pointerleave', giveUp, { signal: session.signal }); + + return true; + }, + }; +}; + +type HintCorridor = Pick, 'cancelCorridor' | 'startCorridor'>; + +/** + * The label as the card's trigger. The card opens beside the panel, out of reach of the close delay, so the trigger + * hands an open card's pointer leave to the session's grace corridor. Escape and blur still close it through the + * hover card. + */ +const HintTrigger = ({ cancelCorridor, children, startCorridor }: HintCorridor & { children: ReactElement }) => { + const hoverCard = useHoverCardContext(); + const triggerProps = hoverCard.getTriggerProps(); + const { onPointerEnter, onPointerLeave } = triggerProps; + + const handlePointerEnter = (event: PointerEvent) => { + cancelCorridor(); + onPointerEnter?.(event); + }; + const handlePointerLeave = (event: PointerEvent) => { + const close = () => onPointerLeave?.(event); + const isHeld = + hoverCard.open && + event.pointerType !== 'touch' && + startCorridor({ x: event.clientX, y: event.clientY }, event.currentTarget, close); + + if (!isHeld) { + close(); + } + }; + + return cloneElement( + children, + mergeProps( + { ...triggerProps, onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }, + children.props as Record + ) + ); +}; + export interface FeatureHintProps { hint: FeatureHintId; /** Becomes the hover target verbatim — it gains data attributes, not a wrapper. */ @@ -107,23 +260,52 @@ export interface FeatureHintProps { */ export const FeatureHint = ({ children, hint }: FeatureHintProps) => { const { enabled, onDisable } = useFeatureHints(); - const positioning = useMemo( - () => ({ gutter: 8, placement: getFeatureHint(hint).placement ?? DEFAULT_HINT_PLACEMENT }), - [hint] + const [{ cancelCorridor, getAnchorRect, resetPanel, setCard, startCorridor }] = useState(createHintSession); + const positioning = useMemo( + () => ({ + getAnchorRect, + gutter: HINT_GUTTER_PX, + overflowPadding: HINT_VIEWPORT_PADDING_PX, + placement: getFeatureHint(hint).placement ?? DEFAULT_HINT_PLACEMENT, + }), + [getAnchorRect, hint] + ); + // The panel around a label can change between openings, never during one. A press outside the card closes it + // through the hover card's dismissal, which ends any corridor with it. + const handleOpenChange = useCallback( + ({ open }: { open: boolean }) => { + if (open) { + resetPanel(); + } else { + cancelCorridor(); + } + }, + [cancelCorridor, resetPanel] ); + useMountEffect(() => cancelCorridor); + if (!enabled) { return children; } return ( - + - {children} + + {children} + - + diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/featureHint.testing.ts b/invokeai/frontend/webv2/src/platform/ui/hints/featureHint.testing.ts new file mode 100644 index 00000000000..780338e90a5 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/ui/hints/featureHint.testing.ts @@ -0,0 +1,81 @@ +import { act } from 'react'; +import { userEvent } from 'vitest/browser'; + +const FRAME_MS = 16; +const STABLE_FRAMES = 3; + +const nextFrame = (): Promise => + act(async () => { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, FRAME_MS); + }); + }); + +/** Move the real pointer to the page corner, so the next hover enters its trigger afresh and emits pointerenter. */ +export const parkPointer = async (): Promise => { + const parking = document.createElement('div'); + + parking.style.cssText = 'position:fixed;left:0;top:0;width:2px;height:2px;z-index:2147483647'; + document.body.append(parking); + await act(() => userEvent.hover(parking)); + parking.remove(); +}; + +/** The open card, which portals out of the test host. */ +export const getHintCard = (): HTMLElement | null => + document.querySelector('[data-scope="hover-card"][data-part="content"]'); + +/** Hover a hint trigger and wait until its card has opened and stopped moving; resolves to the card's rect. */ +export const openHintCard = async (trigger: Element, timeoutMs = 5000): Promise => { + await parkPointer(); + await act(() => userEvent.hover(trigger)); + + const deadline = Date.now() + timeoutMs; + let last: DOMRect | null = null; + let stableFrames = 0; + + while (stableFrames < STABLE_FRAMES) { + if (Date.now() > deadline) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for the hint card to open and settle`); + } + + await nextFrame(); + + const rect = getHintCard()?.getBoundingClientRect() ?? null; + const isSame = rect !== null && last !== null && rect.x === last.x && rect.y === last.y; + + stableFrames = isSame ? stableFrames + 1 : 0; + last = rect; + } + + return last!; +}; + +type Point = { x: number; y: number }; + +/** + * Walk the real pointer from `from` to `to` in straight steps, one pointermove per step: a single hover jumps, which + * never crosses the space between a label and its card. + */ +export const movePointerAlong = async ( + from: Point, + to: Point, + { stepDelayMs, steps }: { stepDelayMs: number; steps: number } +): Promise => { + for (let step = 0; step <= steps; step += 1) { + const x = from.x + ((to.x - from.x) * step) / steps; + const y = from.y + ((to.y - from.y) * step) / steps; + // A momentary 2px target under the point: the real pointer can only be moved by hovering an element. + const probe = document.createElement('div'); + + probe.style.cssText = `position:fixed;left:${x - 1}px;top:${y - 1}px;width:2px;height:2px;z-index:2147483647`; + document.body.append(probe); + await act(() => userEvent.hover(probe)); + probe.remove(); + await act(async () => { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, stepDelayMs); + }); + }); + } +}; diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/hintGeometry.ts b/invokeai/frontend/webv2/src/platform/ui/hints/hintGeometry.ts new file mode 100644 index 00000000000..690a65a1760 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/ui/hints/hintGeometry.ts @@ -0,0 +1,79 @@ +/** Gap between the anchor and the card. */ +export const HINT_GUTTER_PX = 8; +/** Clearance the card keeps from the viewport edge. */ +export const HINT_VIEWPORT_PADDING_PX = 8; +/** The corridor's apex sits this far behind the exit point, so a little jitter on leaving does not close the card. */ +const CORRIDOR_SLACK_PX = 6; + +/** Marks the field a hint explains; the card sits beside it when no scrolling panel can hold it. */ +export const FEATURE_HINT_FIELD_PROPS = { 'data-feature-hint-field': '' } as const; + +const FIELD_SELECTOR = '[data-feature-hint-field]'; + +type Point = { x: number; y: number }; +type AnchorRect = { height: number; width: number; x: number; y: number }; + +/** The nearest vertically scrolling ancestor: the body of the panel, window, or dialog the hint lives in. */ +export const findScrollingPanel = (trigger: HTMLElement): HTMLElement | null => { + for (let element = trigger.parentElement; element && element !== document.body; element = element.parentElement) { + const { overflowY } = getComputedStyle(element); + + if (overflowY === 'auto' || overflowY === 'scroll') { + return element; + } + } + + return null; +}; + +/** + * Spans the panel across and the hovered label down, so the card points at the label from beside the panel instead + * of covering the controls it explains. When neither side of the panel has room for the card (a maximized window), + * the marked field stands in, then the label itself, so the card always stays on screen. + */ +export const getHintAnchorRect = ( + trigger: HTMLElement, + panel: HTMLElement | null, + card: HTMLElement | null +): AnchorRect => { + const label = trigger.getBoundingClientRect(); + // Zero until the card has laid out; the first measurement then repositions it. + const cardWidth = card?.getBoundingClientRect().width ?? 0; + const viewportWidth = document.documentElement.clientWidth; + const hasRoomBeside = (rect: DOMRect): boolean => + Math.max(rect.left, viewportWidth - rect.right) - HINT_GUTTER_PX - HINT_VIEWPORT_PADDING_PX >= cardWidth; + const across = + [panel, trigger.closest(FIELD_SELECTOR)] + .map((element) => element?.getBoundingClientRect()) + .find((rect): rect is DOMRect => rect !== undefined && hasRoomBeside(rect)) ?? label; + + return { height: label.height, width: across.width, x: across.left, y: label.top }; +}; + +const cross = (origin: Point, a: Point, b: Point): number => + (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x); + +/** + * The safe triangle from where the pointer left the label to the card's near edge: moves inside it are on their way + * to the card. Null when the card is not beside the label, where the ordinary close delay applies. + */ +export const createGraceCorridor = (exit: Point, label: DOMRect, card: DOMRect): ((point: Point) => boolean) | null => { + const side = card.left >= label.right ? 1 : card.right <= label.left ? -1 : 0; + + if (side === 0) { + return null; + } + + const edgeX = side === 1 ? card.left : card.right; + const apex = { x: exit.x - side * CORRIDOR_SLACK_PX, y: exit.y }; + const top = { x: edgeX, y: card.top }; + const bottom = { x: edgeX, y: card.bottom }; + + return (point) => { + const a = cross(apex, top, point); + const b = cross(top, bottom, point); + const c = cross(bottom, apex, point); + + return (a >= 0 && b >= 0 && c >= 0) || (a <= 0 && b <= 0 && c <= 0); + }; +}; diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts b/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts index 77f751b0f1c..e66eed1b83e 100644 --- a/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts +++ b/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts @@ -9,7 +9,7 @@ interface HintDefinition { placement?: HintPlacement; } -/** Cards sit beside the panel rather than over the control they explain. */ +/** Cards sit beside the panel (see `FeatureHint`'s anchor) rather than over the control they explain. */ export const DEFAULT_HINT_PLACEMENT: HintPlacement = 'right'; const SUPPORT_ADVANCED_SETTINGS = 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings'; @@ -17,7 +17,6 @@ const SUPPORT_CONCEPTS = 'https://support.invoke.ai/support/solutions/articles/1 /** Keep prose in hints.* and non-translatable metadata here; tests enforce registry/catalog parity. */ export const FEATURE_HINTS = { - aspectRatio: {}, cfgRescale: { href: SUPPORT_ADVANCED_SETTINGS }, cfgScale: { href: 'https://www.youtube.com/watch?v=1OeHEJrsTpI' }, clipEmbed: {}, @@ -25,7 +24,12 @@ export const FEATURE_HINTS = { colorCompensation: {}, concepts: { href: SUPPORT_CONCEPTS }, conditioningRebalance: {}, + conditioningRebalanceGain: {}, + controlLayerWeight: { href: 'https://support.invoke.ai/support/solutions/articles/151000178148' }, creativity: {}, + denoisingStrength: { + href: 'https://support.invoke.ai/support/solutions/articles/151000094998-image-to-image', + }, guidance: {}, height: {}, hidiffusion: { href: 'https://github.com/megvii-research/HiDiffusion' }, @@ -33,13 +37,16 @@ export const FEATURE_HINTS = { hidiffusionT1Ratio: { href: 'https://github.com/megvii-research/HiDiffusion' }, hidiffusionT2Ratio: { href: 'https://github.com/megvii-research/HiDiffusion' }, hidiffusionWindowAttn: { href: 'https://github.com/megvii-research/HiDiffusion' }, - imageInfluence: { - href: 'https://support.invoke.ai/support/solutions/articles/151000094998-image-to-image', - }, + krea2SeedVariance: {}, + krea2SeedVarianceRandomize: {}, + krea2SeedVarianceStrength: {}, layerStackControl: {}, layerStackInpaintMask: {}, layerStackRaster: {}, layerStackRegionalGuidance: {}, + loraWeight: { + href: 'https://support.invoke.ai/support/solutions/articles/151000159072-concepts-low-rank-adaptations-loras-', + }, model: { href: 'https://support.invoke.ai/support/solutions/articles/151000096601-what-is-a-model-which-should-i-use-', }, @@ -59,6 +66,7 @@ export const FEATURE_HINTS = { seed: { href: 'https://support.invoke.ai/support/solutions/articles/151000096684-what-is-a-seed-how-do-i-use-it-to-recreate-the-same-image-', }, + stepRange: { href: 'https://support.invoke.ai/support/solutions/articles/151000178148' }, steps: {}, structure: {}, t5Encoder: {}, diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/index.ts b/invokeai/frontend/webv2/src/platform/ui/hints/index.ts index 91227fb5bb5..ffb8670bc69 100644 --- a/invokeai/frontend/webv2/src/platform/ui/hints/index.ts +++ b/invokeai/frontend/webv2/src/platform/ui/hints/index.ts @@ -1,4 +1,5 @@ // Keep the hints entry outside the UI barrel to preserve its direct-importer budget. export * from './FeatureHint'; +export { FEATURE_HINT_FIELD_PROPS } from './hintGeometry'; export * from './hintRegistry'; export * from './hintsContext'; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateDenoisingStrength.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateDenoisingStrength.tsx index 5af1719ad13..c67465e0235 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateDenoisingStrength.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateDenoisingStrength.tsx @@ -92,6 +92,7 @@ const StrengthField = () => { - + Date: Sun, 18 Oct 2026 12:48:56 -0600 Subject: [PATCH 6/8] fix(hotkeys): keep menus and saved bindings ahead of the recall shortcuts A key press inside an open menu resolves to no hotkey, and a saved binding wins a tie with a default key at the same scope. Hint cards stay open while the pointer rests on the arrow, close normally when a scrub drag leaves the label, and the image influence hint is reattached. The SDXL refiner's Compel node is declared in the Compel table so the drift test covers it. Co-Authored-By: Claude Fable 5.1 --- .../generation/core/graphCoverage.test.ts | 6 +- .../features/generation/core/prompt/compel.ts | 12 +++ .../generation/core/sdxlRefinerGraph.ts | 5 +- .../ReferenceImageControls.browser.test.tsx | 36 +++++++- .../ReferenceImageControls.tsx | 2 +- .../ui/hints/FeatureHint.browser.test.tsx | 52 ++++++++++++ .../src/platform/ui/hints/FeatureHint.tsx | 12 ++- .../webv2/src/workbench/canvasDimsSync.ts | 9 +- .../WorkbenchHotkeyRuntime.browser.test.tsx | 82 +++++++++++++++++- .../webv2/src/workbench/hotkeys/keys.ts | 8 ++ .../src/workbench/hotkeys/resolve.test.ts | 62 +++++++++++++- .../webv2/src/workbench/hotkeys/resolve.ts | 25 ++++-- .../webv2/src/workbench/hotkeys/types.ts | 5 ++ .../HotkeysSettingsSection.browser.test.tsx | 83 +++++++++++++++++++ 14 files changed, 373 insertions(+), 26 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.browser.test.tsx diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts index de7c8b1613e..90cc05836e6 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -24,7 +24,7 @@ import { SHAPE_OVERRIDES, tunedSettingsFor, } from './graphCoverage.testing'; -import { supportsPromptAttentionSyntax } from './prompt/compel'; +import { COMPEL_NODE_TYPES, supportsPromptAttentionSyntax } from './prompt/compel'; const compileForShape = ( base: SupportedGenerateBase, @@ -70,9 +70,7 @@ describe('generate graph coverage', () => { 'encodes $label prompts with Compel exactly when attention syntax is offered', ({ base, shape }) => { const { graph } = compileForShape(base, shape); - const usesCompel = Object.values(graph.nodes).some( - (node) => node.type === 'compel' || node.type === 'sdxl_compel_prompt' - ); + const usesCompel = Object.values(graph.nodes).some((node) => COMPEL_NODE_TYPES.has(node.type)); expect(usesCompel).toBe(supportsPromptAttentionSyntax(base)); } diff --git a/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts index 76cb8b01e29..25c6f98c15a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts @@ -13,6 +13,18 @@ export const COMPEL_PROMPT_NODE_TYPES = { export type CompelPromptBase = keyof typeof COMPEL_PROMPT_NODE_TYPES; +/** + * The SDXL Refiner's encoder, Compel as well: the refiner has no prompts of its own and re-encodes the SDXL prompts, + * so attention syntax carries through its stage. It keys no base: the refiner only ever runs behind an SDXL model. + */ +export const SDXL_REFINER_COMPEL_PROMPT_NODE_TYPE = 'sdxl_refiner_compel_prompt'; + +/** Every node type that reads attention syntax, for graph checks that must not drift from this table. */ +export const COMPEL_NODE_TYPES: ReadonlySet = new Set([ + ...Object.values(COMPEL_PROMPT_NODE_TYPES), + SDXL_REFINER_COMPEL_PROMPT_NODE_TYPE, +]); + /** No model, no reader: an unselected model interprets nothing. */ export const supportsPromptAttentionSyntax = (base: string | null | undefined): base is CompelPromptBase => typeof base === 'string' && Object.hasOwn(COMPEL_PROMPT_NODE_TYPES, base); diff --git a/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts index 18a25c3feb1..b9100a6144c 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts @@ -6,6 +6,7 @@ import type { GenerateSettings, MainModelConfig } from './types'; import { coerceSchedulerForGraph } from './baseGenerationPolicies'; import { addEdge, addNode, toModelIdentifier } from './graphBuilder'; +import { SDXL_REFINER_COMPEL_PROMPT_NODE_TYPE } from './prompt/compel'; export const REFINER_DENOISE_NODE_ID = 'refiner_denoise_latents'; @@ -43,12 +44,12 @@ export const addSDXLRefinerStage = ({ const posCond = addNode(graph, { aesthetic_score: settings.refinerPositiveAestheticScore, id: 'refiner_pos_cond', - type: 'sdxl_refiner_compel_prompt', + type: SDXL_REFINER_COMPEL_PROMPT_NODE_TYPE, }); const negCond = addNode(graph, { aesthetic_score: settings.refinerNegativeAestheticScore, id: 'refiner_neg_cond', - type: 'sdxl_refiner_compel_prompt', + type: SDXL_REFINER_COMPEL_PROMPT_NODE_TYPE, }); const refinerDenoise = addNode(graph, { cfg_scale: settings.refinerCfgScale, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.browser.test.tsx index d722b6e5cf3..d7994e1396e 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.browser.test.tsx @@ -1,6 +1,7 @@ import type { GenerateReferenceImageConfig } from '@features/generation/core/types'; import { ChakraProvider } from '@chakra-ui/react'; +import { FeatureHintsProvider } from '@platform/ui/hints'; import { system } from '@theme/system'; import i18next from 'i18next'; import { act, useCallback, useState } from 'react'; @@ -9,19 +10,26 @@ import { I18nextProvider, initReactI18next } from 'react-i18next'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CLIP_VISION_MODELS } from './referenceImageConfig'; -import { IPAdapterControls } from './ReferenceImageControls'; +import { FluxReduxControls, IPAdapterControls } from './ReferenceImageControls'; const i18n = i18next.createInstance(); await i18n.use(initReactI18next).init({ fallbackLng: 'en', lng: 'en', resources: { - en: { translation: { widgets: { generate: { activeSteps: 'Active steps', mode: 'Mode', weight: 'Weight' } } } }, + en: { + translation: { + widgets: { + generate: { activeSteps: 'Active steps', imageInfluence: 'Image influence', mode: 'Mode', weight: 'Weight' }, + }, + }, + }, }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type IPAdapterConfig = Extract; +type FluxReduxConfig = Extract; const CONFIG: IPAdapterConfig = { beginEndStepPct: [0, 1], @@ -33,6 +41,9 @@ const CONFIG: IPAdapterConfig = { weight: 1, }; +const FLUX_REDUX_CONFIG: FluxReduxConfig = { image: null, imageInfluence: 'highest', model: null, type: 'flux_redux' }; +const HINTS_ON = { enabled: true, onDisable: null }; + /** Track inset the scrubber keeps from its frame edge (see ScrubberField). */ const TRACK_INSET_PX = 10; @@ -97,3 +108,24 @@ describe('IPAdapterControls weight', () => { expect(host.querySelector('[role="slider"]')?.getAttribute('aria-valuenow')).toBe('1'); }); }); + +describe('FluxReduxControls', () => { + it('hints the Image influence label, where the dropdown below cannot hold the card open', async () => { + await act(() => + root.render( + + + + + + + + ) + ); + + const trigger = host.querySelector('[data-scope="hover-card"][data-part="trigger"]'); + + expect(trigger?.textContent).toBe('Image influence'); + expect(trigger?.closest('label')).not.toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx index 3ffcd1aa5cb..d6254bd8691 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx @@ -367,7 +367,7 @@ export const FluxReduxControls = + ', [weightUp, fallback])).toBe('fallback'); }); }); + +describe('resolveHotkey inside an open popup', () => { + const host = document.createElement('div'); + + document.body.append(host); + + afterEach(() => { + host.replaceChildren(); + }); + + const context: HotkeyContext = { + activeInstanceId: 'canvas-1', + activeWidgetTypeId: 'canvas', + focusedRegion: 'center', + isModalPresent: false, + projectId: 'project-1', + }; + const eraser: RegisteredHotkey = { + category: 'app', + commandId: 'eraser', + defaultKeys: ['e'], + id: 'eraser', + implemented: true, + keys: ['e'], + scope: { kind: 'widget', typeIds: ['canvas'] }, + title: 'Eraser', + }; + const invoke = applyCustomHotkeys( + firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === 'app.invoke')!, + {} + ); + + const pressIn = (html: string, matchedKey: string, init: KeyboardEventInit = {}) => { + host.innerHTML = html; + const event = new KeyboardEvent('keydown', { bubbles: true, ...init }); + host.querySelector('[data-target]')!.dispatchEvent(event); + + return resolveHotkey({ context, event, hotkeys: [eraser, invoke], matchedKey })?.commandId ?? null; + }; + + it('leaves unmodified presses in a menu or listbox to the popup, Shift included', () => { + expect(pressIn('
x
', 'e', { key: 'e' })).toBeNull(); + expect(pressIn('
', 'e', { key: 'e' })).toBeNull(); + expect( + pressIn('
', 'shift+e', { key: 'E', shiftKey: true }) + ).toBeNull(); + // Outside a popup the same press is the canvas tool. + expect(pressIn('
', 'e', { key: 'e' })).toBe('eraser'); + }); + + it('still resolves a modified chord pressed inside a popup', () => { + const mod = IS_MAC_OS ? { metaKey: true } : { ctrlKey: true }; + expect( + pressIn('
x
', 'mod+enter', { key: 'Enter', ...mod }) + ).toBe('app.invoke'); + expect(pressIn('
', 'mod+enter', { key: 'Enter', ...mod })).toBe( + 'app.invoke' + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts index 3543180bf80..f40287d1dbb 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts @@ -313,36 +313,6 @@ describe('resolveHotkey', () => { expect(resolve([customGlobal, recallSeed])).toBe('recall'); }); - it('leaves every key pressed inside an open menu to the menu', () => { - class FakeElement { - closest(selector: string): FakeElement | null { - return selector === '[role="menu"]' ? this : null; - } - } - vi.stubGlobal('Element', FakeElement); - const hotkey: RegisteredHotkey = { - ...base, - allowInModal: true, - commandId: 'global', - id: 'global', - scope: { kind: 'global' }, - title: 'Global', - }; - - try { - expect( - resolveHotkey({ - context, - event: { target: new FakeElement() } as unknown as KeyboardEvent, - hotkeys: [hotkey], - matchedKey: 'x', - }) - ).toBeNull(); - } finally { - vi.unstubAllGlobals(); - } - }); - it('keeps an empty custom key array as a disabled hotkey', () => { expect(applyCustomHotkeys({ defaultKeys: ['x'], id: 'app.invoke' }, { 'app.invoke': [] }).keys).toEqual([]); }); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts index dc660dfbd21..5419a5d4c63 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.ts @@ -1,6 +1,6 @@ import type { HotkeyContext, RegisteredHotkey } from './types'; -import { IS_MAC_OS, isEditableHotkeyTarget, isMenuHotkeyTarget, normalizeHotkeyString } from './keys'; +import { IS_MAC_OS, isEditableHotkeyTarget, normalizeHotkeyString } from './keys'; /** * The keys a binding presses on this platform: `mod` is Cmd on macOS and Ctrl elsewhere. The runtime fires the first @@ -58,6 +58,20 @@ export const applyCustomHotkeys = + !event.ctrlKey && + !event.metaKey && + !event.altKey && + typeof Element !== 'undefined' && + event.target instanceof Element && + event.target.closest('[role="menu"], [role="listbox"]') !== null; + const customizedRank = (hotkey: RegisteredHotkey): number => (hotkey.customized ? 1 : 0); export const resolveHotkey = ({ @@ -71,7 +85,7 @@ export const resolveHotkey = ({ hotkeys: RegisteredHotkey[]; matchedKey: string; }): RegisteredHotkey | null => { - if (isMenuHotkeyTarget(event.target)) { + if (isPopupOwnedPress(event)) { return null; } 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 b808887c482..75a7ebc7c01 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 @@ -1,14 +1,34 @@ +import type { ExtensionRegistry } from '@workbench/extensions/extensionRegistry'; +import type * as projectsApi from '@workbench/projects/api'; +import type * as workbenchContext from '@workbench/WorkbenchContext'; import type { ComponentProps } from 'react'; import { ChakraProvider } from '@chakra-ui/react'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; import { system } from '@theme/system'; +import { createExtensionRegistry } from '@workbench/extensions/extensionRegistry'; +import { patchWorkbenchPreferences } from '@workbench/settings/store'; import i18n from 'i18next'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { initReactI18next } from 'react-i18next'; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { page, userEvent } from 'vitest/browser'; +/** The hotkeys the Canvas widget registered, as the workbench provides them; none unless a test installs some. */ +const workbench = vi.hoisted(() => ({ extensions: null as ExtensionRegistry | null })); + +// Saving the remap below would otherwise sync to a backend the browser tests do not run. +vi.mock('@workbench/projects/api', async (importOriginal) => ({ + ...(await importOriginal()), + setClientStateValue: async () => {}, +})); + +vi.mock('@workbench/WorkbenchContext', async (importOriginal) => ({ + ...(await importOriginal()), + useOptionalWorkbenchExtensions: () => workbench.extensions, +})); + import { recordSelectFamilyTool } from './toolFamilyStore'; import { ToolStrip } from './ToolStrip'; @@ -89,6 +109,8 @@ describe('tool strip family slots', () => { }); afterEach(async () => { + workbench.extensions = null; + accountLifecycle.invalidate(); await act(() => root?.unmount()); container?.remove(); container = null; @@ -296,6 +318,45 @@ describe('tool strip family slots', () => { await expect.poll(() => document.activeElement).toBe(shape); }); + it('chooses an entry by the binding its keycaps show, remaps included, and closes the menu', 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 { + const fake = await renderStrip(); + const select = (await button('Marquee select').element()) as HTMLElement; + await openByContextMenu(select); + const lasso = (await page.getByRole('menuitemradio', { name: /^Lasso select/ }).element()) as HTMLElement; + // The keycap shows the remap, not the default L. + expect(lasso.textContent).toBe('Lasso selectk'); + + // The workbench hotkeys listen on window; the chosen entry's press must not also run there. + const windowKeys: string[] = []; + const recordKey = (event: KeyboardEvent) => windowKeys.push(event.key); + window.addEventListener('keydown', recordKey); + await act(() => userEvent.keyboard('k')); + window.removeEventListener('keydown', recordKey); + + expect(fake.activeTool).toBe('lasso'); + expect(windowKeys).toEqual([]); + await expect.element(menu()).not.toBeInTheDocument(); + await expect.poll(() => document.activeElement).toBe(select); + } finally { + await patchWorkbenchPreferences({ customHotkeys: {} }); + } + }); + 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 22adb727b21..64a1729921a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx @@ -10,7 +10,6 @@ import { Box } from '@chakra-ui/react'; import { Toolbar, ToolbarButton } from '@platform/ui/Toolbar'; import { GradientIcon } from '@platform/ui/VendoredIcon'; import { useExtensionHotkeyDefinitions } from '@workbench/hotkeys/extensionHotkeys'; -import { formatHotkeyForPlatform } from '@workbench/hotkeys/keys'; import { applyCustomHotkeys } from '@workbench/hotkeys/resolve'; import { useWorkbenchPreferenceSelector } from '@workbench/settings/store'; import { @@ -120,17 +119,16 @@ const ShapeFamilyButton = ({ }; /** - * A tool's first effective binding, remaps included, from the hotkeys the canvas widget registered; none outside a + * A tool's effective bindings, remaps included, from the hotkeys the canvas widget registered; none outside a * workbench or while unbound. */ -const toolShortcut = ( +const toolHotkeys = ( definitions: readonly HotkeyDefinition[], customHotkeys: Record, hotkeyId: string ): string[] | undefined => { const definition = definitions.find((hotkey) => hotkey.id === hotkeyId); - const key = definition ? applyCustomHotkeys(definition, customHotkeys).keys[0] : undefined; - return key ? formatHotkeyForPlatform(key) : undefined; + return definition ? applyCustomHotkeys(definition, customHotkeys).keys : undefined; }; /** The Select slot: marquee and lasso as one family; the slot stands for the last one used. */ @@ -159,13 +157,13 @@ const SelectFamilyButton = ({ icon: SquareDashedIcon, id: 'marquee', label: t('widgets.canvas.tools.marquee'), - shortcut: toolShortcut(hotkeyDefinitions, customHotkeys, 'canvas.tool.marquee'), + hotkeys: toolHotkeys(hotkeyDefinitions, customHotkeys, 'canvas.tool.marquee'), }, { icon: LassoIcon, id: 'lasso', label: t('widgets.canvas.tools.lasso'), - shortcut: toolShortcut(hotkeyDefinitions, customHotkeys, 'canvas.tool.lasso'), + hotkeys: toolHotkeys(hotkeyDefinitions, customHotkeys, 'canvas.tool.lasso'), }, ], [customHotkeys, hotkeyDefinitions, t] diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStripFlyout.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStripFlyout.tsx index 02f5ce67752..134f458feaf 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStripFlyout.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStripFlyout.tsx @@ -6,6 +6,7 @@ import { IconButton } from '@platform/ui/Button'; import { MenuContent } from '@platform/ui/Menu'; import { Tooltip, useTooltipTriggerIds } from '@platform/ui/Tooltip'; import { ShortcutKeycaps } from '@workbench/hotkeys/keyGlyphs'; +import { formatHotkeyForPlatform, isHotkeyPress } from '@workbench/hotkeys/keys'; import { useCallback, useRef } from 'react'; const HOLD_OPEN_MS = 350; @@ -18,8 +19,8 @@ export interface ToolFlyoutItem { id: string; icon: React.ElementType; label: string; - /** The subtool's effective hotkey, already formatted for the platform. */ - shortcut?: readonly string[]; + /** The subtool's effective bindings, remaps included. The first shows as the entry's keycaps. */ + hotkeys?: readonly string[]; } const isOverElement = (element: Element, event: ReactPointerEvent): boolean => { @@ -207,9 +208,25 @@ export const ToolFamilyButton = ({ if (event.key === 'ArrowLeft') { event.preventDefault(); menu.setOpen(false); + return; + } + // An open menu keeps unmodified keys from the workbench hotkeys, so the keycaps an entry shows are honoured + // here: the entry's binding chooses it, as a click would, instead of moving the typeahead highlight. + if (disabled) { + return; + } + const item = items.find((candidate) => + candidate.hotkeys?.some((hotkey) => isHotkeyPress(event.nativeEvent, hotkey)) + ); + if (item) { + event.preventDefault(); + // A modified binding also resolves as a workbench hotkey on window; the menu has already handled it. + event.stopPropagation(); + onSelectSubtool(item.id); + menu.setOpen(false); } }, - [menu] + [disabled, items, menu, onSelectSubtool] ); return ( @@ -259,9 +276,9 @@ export const ToolFamilyButton = ({ {item.label} - {item.shortcut && item.shortcut.length > 0 ? ( + {item.hotkeys?.[0] ? ( - + ) : null}