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/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx index b20c1f209f5..0c1feaa1881 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/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 7a451ab4c18..18f0fd3956d 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -252,6 +252,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", @@ -559,6 +560,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", @@ -791,6 +793,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", @@ -1063,6 +1066,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 fd47504b034..5ee194d3e94 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": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 30, "scriptRawBytes": 1852857, "scriptRequestCount": 26, @@ -68,7 +68,7 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 30, "scriptRawBytes": 1852857, "scriptRequestCount": 26, @@ -106,7 +106,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 88, "scriptRawBytes": 4131338, "scriptRequestCount": 83, @@ -144,7 +144,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 88, "scriptRawBytes": 4131338, "scriptRequestCount": 83, @@ -182,7 +182,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 97, "scriptRawBytes": 4647483, "scriptRequestCount": 92, @@ -220,7 +220,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 97, "scriptRawBytes": 4647483, "scriptRequestCount": 92, @@ -258,7 +258,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 95, "scriptRawBytes": 4575049, "scriptRequestCount": 89, @@ -296,7 +296,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 95, "scriptRawBytes": 4575049, "scriptRequestCount": 89, @@ -334,7 +334,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 88, "scriptRawBytes": 4131338, "scriptRequestCount": 83, @@ -372,7 +372,7 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752421, - "otherRawBytes": 211323, + "otherRawBytes": 216879, "requestCount": 88, "scriptRawBytes": 4131338, "scriptRequestCount": 83, @@ -663,6 +663,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", @@ -983,6 +984,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", @@ -1334,6 +1336,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", @@ -1772,6 +1775,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", @@ -2076,6 +2080,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", @@ -2432,6 +2437,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", @@ -3328,6 +3334,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", @@ -3755,6 +3762,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", @@ -4193,6 +4201,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/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 7e0229c4875..4299aa3a043 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -801,13 +801,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": [ @@ -845,12 +838,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": [ @@ -890,11 +906,10 @@ ] }, "imageInfluence": { - "heading": "Denoising Strength", + "heading": "Image Influence", "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." + "How strongly a FLUX Redux reference image steers the generation, from Lowest to Highest.", + "Lower settings leave more room for the prompt. Highest follows the reference most closely." ] }, "hidiffusion": { @@ -932,6 +947,27 @@ "Can boost local detail, but may affect global coherence." ] }, + "seedVariance": { + "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." + ] + }, + "seedVarianceRandomize": { + "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." + ] + }, + "seedVarianceStrength": { + "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": [ @@ -960,6 +996,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": [ @@ -970,7 +1010,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": { @@ -986,7 +1026,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": { @@ -1039,6 +1079,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/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 8b6ec987446..598c1dbbf05 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -54,6 +54,7 @@ import { } from './graphBuilder'; import { addKrea2ConditioningEnhancers } from './krea2Conditioning'; import { addPidDecode, getPidDenoiseSize, getPidMetadata, isPidDecodeNodeType, shouldUsePidDecode } from './pidGraph'; +import { getCompelPromptNodeType } from './prompt/compel'; import { getEffectiveReferenceImage } from './referenceImage'; import { getActiveRefiner, getBaseDenoisingRange } from './sdxlRefiner'; import { addSDXLRefinerStage, getRefinerMetadata } from './sdxlRefinerGraph'; @@ -318,7 +319,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 ee15380530b..6a8150a81f3 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -24,6 +24,8 @@ import { SHAPE_OVERRIDES, tunedSettingsFor, } from './graphCoverage.testing'; +import { COMPEL_NODE_TYPES, supportsPromptAttentionSyntax } from './prompt/compel'; +import { REFINER_DENOISE_NODE_ID } from './sdxlRefinerGraph'; const compileForShape = ( base: SupportedGenerateBase, @@ -51,6 +53,25 @@ const SDXL_REFINER_MODEL: MainModelConfig = { type: 'main', }; +// The refiner is a per-image choice, not a component slot, so it needs its own SDXL case. +const refinerCase = () => { + const { filled, model, settings } = satisfiedSettingsFor('sdxl', SDXL_REFINER_SHAPE); + const withRefiner = { ...settings, refinerModel: SDXL_REFINER_MODEL }; + + expect(getGenerationValidationReasons(model, withRefiner)).toEqual([]); + + return { + base: 'sdxl' as const, + filled, + graph: compileGenerateGraph(withRefiner, model, 'gallery', { + useCpuNoise: true, + useNsfwChecker: false, + useWatermark: false, + }).backendGraph, + shape: SDXL_REFINER_SHAPE, + }; +}; + seedArchitectureCapabilities(); describe('generate graph coverage', () => { @@ -64,6 +85,37 @@ 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) => COMPEL_NODE_TYPES.has(node.type)); + + expect(usesCompel).toBe(supportsPromptAttentionSyntax(base)); + } + ); + + // SDXL attention syntax carries through the refiner stage, which re-encodes the prompts with its own encoders. + it('conditions the SDXL refiner denoise with Compel encoders', () => { + const { graph } = refinerCase(); + const conditioningSource = (field: string) => { + const sources = graph.edges.filter( + (edge) => edge.destination.node_id === REFINER_DENOISE_NODE_ID && edge.destination.field === field + ); + + expect(sources, `refiner denoise ${field} has no single source`).toHaveLength(1); + + return graph.nodes[sources[0]!.source.node_id]!.type; + }; + + for (const field of ['positive_conditioning', 'negative_conditioning']) { + const sourceType = conditioningSource(field); + + expect(COMPEL_NODE_TYPES.has(sourceType), `refiner ${field} is encoded by ${sourceType}`).toBe(true); + } + }); + 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)); @@ -179,24 +231,6 @@ describe('generate graph coverage', () => { const fieldsFor = (nodeType: string) => (fields[nodeType] ??= { inputs: new Set(), literals: new Map(), outputs: new Set() }); - // The refiner is a per-image choice, not a component slot, so it needs its own SDXL case. - const refinerCase = () => { - const { filled, model, settings } = satisfiedSettingsFor('sdxl', SDXL_REFINER_SHAPE); - const withRefiner = { ...settings, refinerModel: SDXL_REFINER_MODEL }; - - expect(getGenerationValidationReasons(model, withRefiner)).toEqual([]); - - return { - base: 'sdxl' as const, - filled, - graph: compileGenerateGraph(withRefiner, model, 'gallery', { - useCpuNoise: true, - useNsfwChecker: false, - useWatermark: false, - }).backendGraph, - shape: SDXL_REFINER_SHAPE, - }; - }; // Each shape compiles twice: at its defaults, and with every optional tuning and output filter switched on. const compilations = [ ...generateGraphCases.flatMap(({ base, shape }) => 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..25c6f98c15a --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/prompt/compel.ts @@ -0,0 +1,39 @@ +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; + +/** + * 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); + +/** 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/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/core/settings.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.ts index 4135ee97914..dea964ea145 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.ts @@ -147,6 +147,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/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/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 26baa285373..608fe1915ae 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -13,6 +13,7 @@ export { getDefaultLoraWeight, getModelDefaultVae, hasModelDefaultVae, + isAspectRatioLocked, isFluxDypePreset, isLoraCompatibleWithModel, isLoraModelConfig, 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 = ({ > (null); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx index cf17080fc6b..34e3d698896 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx @@ -284,6 +284,7 @@ const SeedVarianceFields = ({ <> { 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 = () => ( ; +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 c963445f3fb..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 @@ -270,7 +270,7 @@ export const IPAdapterControls = ({ ) : null} - + {formatPct(beginEndStepPct[0])} – {formatPct(beginEndStepPct[1])} diff --git a/invokeai/frontend/webv2/src/features/generation/ui/shared/ConceptRow.tsx b/invokeai/frontend/webv2/src/features/generation/ui/shared/ConceptRow.tsx index ebc80d85069..6266a046acb 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/shared/ConceptRow.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/shared/ConceptRow.tsx @@ -251,6 +251,7 @@ const ConceptRowContent = ({ { const render = async (hinted: boolean) => { host = document.createElement('div'); + // Inset from the parked pointer, and narrow enough to leave room for a hint card beside the field. + host.style.cssText = 'width:120px;margin:120px 0 0 8px'; document.body.append(host); root = createRoot(host); @@ -86,4 +89,12 @@ describe('Field with a hint', () => { expect(hosted.querySelector('label [data-part="trigger"]')?.textContent).toBe('Steps'); }); + + it('opens the card beside the field instead of over its control', async () => { + const hosted = await render(true); + const control = hosted.querySelector('input')!.getBoundingClientRect(); + const card = await openHintCard(hosted.querySelector('[data-part="trigger"]')!); + + expect(card.left).toBeGreaterThanOrEqual(control.right); + }); }); diff --git a/invokeai/frontend/webv2/src/platform/ui/Field.tsx b/invokeai/frontend/webv2/src/platform/ui/Field.tsx index a55a7781bd9..2377cf1414d 100644 --- a/invokeai/frontend/webv2/src/platform/ui/Field.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/Field.tsx @@ -11,7 +11,7 @@ import { useRecipe, type StackProps, } from '@chakra-ui/react'; -import { FeatureHint } from '@platform/ui/hints'; +import { FEATURE_HINT_FIELD_PROPS, FeatureHint } from '@platform/ui/hints'; import { fieldLabelRecipe } from '@theme/recipes'; import { createContext, useContext, useMemo } from 'react'; @@ -81,6 +81,7 @@ export const Field = ({ const recipe = useRecipe({ recipe: fieldLabelRecipe }); const isHorizontal = orientation === 'horizontal'; const isInvalid = invalid ?? Boolean(error); + const hintFieldProps = hint ? FEATURE_HINT_FIELD_PROPS : undefined; const ids = useMemo( () => id @@ -127,7 +128,7 @@ export const Field = ({ required={required} unstyled > - + {labelContent} {labelEnd} @@ -150,7 +151,7 @@ export const Field = ({ required={required} unstyled > - + {labelContent} {labelEnd} diff --git a/invokeai/frontend/webv2/src/platform/ui/ScrubberField.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/ScrubberField.browser.test.tsx index 62d697a4c31..2633035f0ef 100644 --- a/invokeai/frontend/webv2/src/platform/ui/ScrubberField.browser.test.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/ScrubberField.browser.test.tsx @@ -7,6 +7,8 @@ import { I18nextProvider, initReactI18next } from 'react-i18next'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { userEvent } from 'vitest/browser'; +import { getHintCard, openHintCard, parkPointer } from './hints/featureHint.testing'; +import { type FeatureHintsAdapter, FeatureHintsProvider } from './hints/hintsContext'; import { ScrubberField, type ScrubberFieldProps } from './ScrubberField'; const i18n = i18next.createInstance(); @@ -804,3 +806,100 @@ describe('ScrubberField log scale and value-dependent steps', () => { expect(steps().slice(3)).toEqual([1, 0.99, 0.89, 0.99, 100]); }); }); + +describe('ScrubberField hints', () => { + const HINT_RESOURCES = { + hints: { steps: { heading: 'Steps', paragraphs: ['Number of steps that will be performed.'] } }, + }; + + i18n.addResourceBundle('en', 'translation', HINT_RESOURCES, true, false); + + const HINTS_ON: FeatureHintsAdapter = { enabled: true, onDisable: null }; + const HINTS_OFF: FeatureHintsAdapter = { enabled: false, onDisable: null }; + /** Inset from the parked pointer, and narrow enough to leave room for the card beside the field. */ + const HOST_CSS = 'width:120px;margin:120px 0 0 8px'; + + const mountWithHints = async (enabled: boolean) => { + const onChange = vi.fn(); + + host = document.createElement('div'); + host.style.cssText = HOST_CSS; + document.body.append(host); + root = createRoot(host); + + await act(() => { + root?.render( + + + + + + + + ); + }); + + const frame = host.querySelector('[data-scope="scrubber"]')!; + const slider = host.querySelector('[role="slider"]')!; + const label = host.querySelector('[data-part="label"]')!; + + return { frame, label, onChange, slider }; + }; + + it('opens the hint card from the label beside the field, keeping the slider named by the label', async () => { + const { frame, label, slider } = await mountWithHints(true); + const card = await openHintCard(label.querySelector('[data-part="trigger"]')!); + + expect(getHintCard()?.textContent).toContain('Number of steps that will be performed.'); + // Beside the frame, not over the track and value the hint explains. + expect(card.left).toBeGreaterThanOrEqual(frame.getBoundingClientRect().right); + expect(document.getElementById(slider.getAttribute('aria-labelledby') ?? '')?.textContent).toBe('Steps'); + }); + + it('closes the card when a press on the field starts other work', async () => { + const { label } = await mountWithHints(true); + + await openHintCard(label.querySelector('[data-part="trigger"]')!); + // The value sits between the label and the card, inside the corridor to the card. + await act(() => userEvent.click(valueButton()!)); + // Past the close delay, short of the corridor's grace window. + await act(async () => { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 500); + }); + }); + + expect(getHintCard()).toBeNull(); + }); + + it('opens no card while hints are off', async () => { + const { label } = await mountWithHints(false); + + await parkPointer(); + await act(() => userEvent.hover(label)); + // Past the hint's open delay. + await act(async () => { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 900); + }); + }); + + expect(document.querySelector('[data-scope="hover-card"]')).toBeNull(); + }); + + it('still scrubs from a press on the hinted label', async () => { + const { frame, label, onChange } = await mountWithHints(true); + const { left, top } = label.getBoundingClientRect(); + + await pointer(label, 'pointerdown', { clientX: left + 4, clientY: top + 4 }); + + expect(frame.hasAttribute('data-dragging')).toBe(true); + + const trackWidth = frame.getBoundingClientRect().width - TRACK_INSET_PX * 2; + + await pointer(window, 'pointermove', { clientX: left + 4 + trackWidth * 0.2 }); + + expect(onChange).toHaveBeenLastCalledWith(50); + await pointer(window, 'pointerup', {}); + }); +}); diff --git a/invokeai/frontend/webv2/src/platform/ui/ScrubberField.tsx b/invokeai/frontend/webv2/src/platform/ui/ScrubberField.tsx index 9a459bd2e20..75e3c8a7d8a 100644 --- a/invokeai/frontend/webv2/src/platform/ui/ScrubberField.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/ScrubberField.tsx @@ -10,7 +10,7 @@ import type { import { Box, chakra, Stack, Text } from '@chakra-ui/react'; import { useMountEffect } from '@platform/react/useMountEffect'; -import { FeatureHint } from '@platform/ui/hints'; +import { FEATURE_HINT_FIELD_PROPS, FeatureHint } from '@platform/ui/hints'; import { fieldLabelRecipe, scrubberInteraction } from '@theme/recipes'; import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -687,7 +687,7 @@ export const ScrubberField = ({ ); return ( - + => { - 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(async () => { - await userEvent.hover(parking); - }); - - parking.remove(); -}; - +// Reset the pointer after each test so a newly mounted trigger cannot open under the previous test's cursor. afterEach(async () => { await act(() => root?.unmount()); host?.remove(); @@ -106,6 +98,57 @@ const waitForCard = async (timeoutMs = 5000): Promise => { } }; +const PANEL_CSS = 'position:fixed;top:120px;height:200px;overflow-y:auto'; + +/** + * A hinted label above its control inside `host`: a scrolling panel when its CSS scrolls, like a docked widget + * body. With `field`, the label and control sit in a marked 100px field. + */ +const renderHint = async (hostCss: string, { field = false, labels = ['CLIP skip'] } = {}) => { + host = document.createElement('div'); + host.style.cssText = hostCss; + document.body.append(host); + root = createRoot(host); + + const content = ( + <> + {labels.map((label) => ( + + + {label} + + + ))} + + + ); + + 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,160 @@ 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('stays open through a rest on the arrow, which lies outside the card body', async () => { + const { trigger } = await renderHint(LEFT_PANEL_CSS); + const card = await openHintCard(trigger); + const arrow = document.querySelector('[data-scope="hover-card"][data-part="arrow"]')!; + const arrowRect = arrow.getBoundingClientRect(); + const label = trigger.getBoundingClientRect(); + const exit = { x: label.right - 2, y: label.top + label.height / 2 }; + // The arrow straddles the card's edge: its outer half is clear of the content rect. + const outer = { x: arrowRect.left + 1, y: arrowRect.top + arrowRect.height / 2 }; + + expect(outer.x).toBeLessThan(card.left); + await movePointerAlong(exit, { x: outer.x - 6, y: outer.y }, MOVE); + await act(() => + userEvent.hover(arrow, { position: { x: outer.x - arrowRect.left, y: outer.y - arrowRect.top } }) + ); + // Past the corridor's grace window and the close delay after it. + await settle(1500); + await act(() => userEvent.hover(document.querySelector('[data-scope="hover-card"][data-part="content"] h2')!)); + await settle(500); + + expect(cardText()).toContain('CLIP Skip'); + }); + + it('closes at the ordinary delay when the pointer leaves with a button held, as a scrub does', async () => { + const { trigger } = await renderHint(LEFT_PANEL_CSS); + const card = await openHintCard(trigger); + const label = trigger.getBoundingClientRect(); + const held = { + bubbles: true, + buttons: 1, + clientX: label.right - 2, + clientY: label.top + label.height / 2, + pointerType: 'mouse', + }; + + // A scrubber taking pointer capture on press moves the pointer off the label with the button still down. + await act(() => trigger.dispatchEvent(new PointerEvent('pointerdown', held))); + expect(cardText()).toContain('CLIP Skip'); + await act(() => trigger.dispatchEvent(new PointerEvent('pointerout', { ...held, relatedTarget: document.body }))); + // A rightward drag heads toward the card; within the grace window, a corridor would keep the card open. + for (const step of [1, 2, 3]) { + await act(() => + document.dispatchEvent(new PointerEvent('pointermove', { ...held, clientX: held.clientX + step * 8 })) + ); + await settle(100); + } + await settle(400); + + expect(card.left).toBeGreaterThan(held.clientX); + expect(cardText()).toBe(''); + }); + + 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..96a4a6916ca 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,150 @@ 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(); + }; + // The arrow lies outside the content rect, so the event target counts as well as the point. + const isOverCard = (x: number, y: number, target: EventTarget | null) => + (x >= cardRect.left && x <= cardRect.right && y >= cardRect.top && y <= cardRect.bottom) || + (target instanceof Node && openCard.contains(target)); + 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, target }) => { + // Over the card, or closed some other way: the hover card owns what happens next. + if (!openCard.isConnected || isOverCard(x, y, target)) { + 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); + // A held button is a drag, such as a scrubber taking pointer capture, not a trip to the card. + const isHeld = + hoverCard.open && + event.pointerType !== 'touch' && + event.buttons === 0 && + 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 +264,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 dc39ce67ed6..435273cfcd5 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', + }, dypePreset: { href: 'https://github.com/guyyariv/DyPE' }, encoderQuantization: {}, guidance: {}, @@ -35,13 +39,14 @@ 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', - }, + imageInfluence: {}, 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-', }, @@ -68,6 +73,10 @@ 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-', }, + seedVariance: {}, + seedVarianceRandomize: {}, + seedVarianceStrength: {}, + stepRange: { href: 'https://support.invoke.ai/support/solutions/articles/151000178148' }, steps: {}, structure: {}, styleStrength: {}, 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/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/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..97f05db48b5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts @@ -1,20 +1,28 @@ /** * 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. The Custom render size + * follows the ratio the Size section holds, whether a chosen preset, the lock button, or a size action such as recall + * or a reference image's "use size" set it: those write a preset label, which the Size section reads as a lock. The one + * exception is a free frame drag, whose derived label does not carry the Custom size unless the person's own lock is + * on. 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 +127,7 @@ export interface CanvasDimsSyncStore { commands: { canvas: Pick; generation: Pick; + widgets: Pick; }; getState(): WorkbenchState; subscribe(listener: () => void): () => void; @@ -148,24 +157,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 +195,7 @@ export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync if (project.invocation.sourceId !== 'canvas') { prev = null; - return; + return false; } const generateValues = getProjectWidgetValues(project, 'generate'); @@ -184,7 +204,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 +214,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 +223,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/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx index 881d11ea191..2ec6ba7df28 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.browser.test.tsx @@ -4,14 +4,17 @@ import type { Project } from '@workbench/projectContracts'; import type { WidgetContributionSource } from '@workbench/widgetContracts'; import type { WorkbenchInternalStore } from '@workbench/workbenchStore'; -import { ChakraProvider } from '@chakra-ui/react'; +import { ChakraProvider, createListCollection, Menu, Portal, Select } from '@chakra-ui/react'; +import { PromptTextarea } from '@features/generation/components'; +import { adjustFocusedPromptAttention } from '@features/generation/react'; +import { MenuContent } from '@platform/ui/Menu'; import { AppToaster, createActionToast, toaster } from '@platform/ui/toaster'; import { system } from '@theme/system'; import { createExtensionRegistry } from '@workbench/extensions/extensionRegistry'; import { useFloatingWindowFocus, useFocusRegionProps } from '@workbench/focusRegions'; import { WorkbenchFocusProvider } from '@workbench/WorkbenchRuntime'; import { createWorkbenchStore } from '@workbench/workbenchStore'; -import { act, useCallback } from 'react'; +import { act, useCallback, useState, type ChangeEvent } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { userEvent } from 'vitest/browser'; @@ -38,6 +41,7 @@ vi.mock('@workbench/WorkbenchContext', () => ({ })); vi.mock('./firstPartyCommands', () => ({ useRegisterFirstPartyCommands: () => {} })); +import { IS_MAC_OS } from './keys'; import { WorkbenchHotkeyRuntime } from './WorkbenchHotkeyRuntime'; const Harness = () => { @@ -87,6 +91,20 @@ const pressKey = (key: string, target: EventTarget = document.body) => ); await Promise.resolve(); }); +/** Mod+Comma, Settings: a global chord the popups leave alone (Zag menus swallow Enter whatever the modifiers). */ +const pressOpenSettings = (target: EventTarget) => + act(async () => { + target.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'Comma', + key: ',', + ...(IS_MAC_OS ? { metaKey: true } : { ctrlKey: true }), + }) + ); + await Promise.resolve(); + }); const pressOn = (testId: string) => act(() => host!.querySelector(`[data-testid="${testId}"]`)!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) @@ -225,3 +243,274 @@ 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.useSize', + 'viewer.loadWorkflow', + '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('d', gallery); + await pressKey('w', gallery); + await pressKey('s', preview); + + expect(ran).toEqual([ + 'viewer.recallAll', + 'viewer.recallSeed', + 'viewer.recallPrompts', + 'viewer.useSize', + 'viewer.loadWorkflow', + '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']); + }); +}); + +describe('WorkbenchHotkeyRuntime and open menus', () => { + let menuRoot: Root | null = null; + + beforeEach(() => { + // First-party commands are registered without a source, as `useRegisterFirstPartyCommands` does. + for (const id of ['viewer.recallSeed', 'app.openSettings']) { + runtimeMocks.extensions.commands.register({ handler: () => ran.push(id), id, title: id }); + } + }); + + afterEach(async () => { + await act(() => menuRoot?.unmount()); + menuRoot = null; + }); + + /** A gallery item's context menu: portaled content, so its DOM lies outside every widget. */ + const ImageMenu = () => ( + + Image actions + + + + Select + Star + + + + + ); + + const sizes = createListCollection({ items: ['Small', 'Seed', 'Square'] }); + /** A settings Select: portaled content that takes focus as a `listbox` and runs its own typeahead. */ + const SizeSelect = () => ( + + + + + + + + + + {sizes.items.map((item) => ( + + {item} + + ))} + + + + + ); + + /** Gallery holds workbench focus, then the popup opens from its trigger and takes focus. */ + const openPopup = async (popup: React.ReactNode, role: 'listbox' | 'menu') => { + await pressOn('right'); + const menuHost = document.createElement('div'); + host!.append(menuHost); + menuRoot = createRoot(menuHost); + await act(() => { + menuRoot?.render({popup}); + }); + await act(() => userEvent.click(menuHost.querySelector('button')!)); + const content = await vi.waitFor(() => { + const element = document.querySelector(`[role="${role}"][data-state="open"]`); + expect(element).not.toBeNull(); + return element!; + }); + await vi.waitFor(() => expect(document.activeElement).toBe(content)); + return content; + }; + + it('still runs a global chord pressed inside a portaled menu', async () => { + const menu = await openPopup(, 'menu'); + + await pressKey('s', menu); + await pressOpenSettings(menu); + + expect(ran).toEqual(['app.openSettings']); + }); + + it('leaves a letter typed into a portaled Select listbox to the Select while Gallery holds focus', async () => { + const listbox = await openPopup(, 'listbox'); + + await pressKey('s', listbox); + expect(ran).toEqual([]); + await pressOpenSettings(listbox); + expect(ran).toEqual(['app.openSettings']); + }); + + it('leaves a letter typed into a portaled menu to the menu while Gallery holds focus', async () => { + // Gallery is the right region's active widget: a press outside any widget's DOM resolves against it. + await pressOn('right'); + await pressKey('s'); + expect(ran).toEqual(['viewer.recallSeed']); + + const menuHost = document.createElement('div'); + host!.append(menuHost); + menuRoot = createRoot(menuHost); + await act(() => { + menuRoot?.render( + + + + ); + }); + await act(() => userEvent.click(menuHost.querySelector('button')!)); + const menu = await vi.waitFor(() => { + const content = document.querySelector('[role="menu"][data-state="open"]'); + expect(content).not.toBeNull(); + return content!; + }); + // Zag focuses the content on open; typeahead keydowns target it, prevent their default, and still bubble. + await vi.waitFor(() => expect(document.activeElement).toBe(menu)); + + await pressKey('s', menu); + await pressKey('s', menu.querySelector('[role="menuitem"]')!); + + expect(ran).toEqual(['viewer.recallSeed']); + // Gallery still holds workbench focus: the menu, not lost focus, kept the key. + await pressKey('s'); + expect(ran).toEqual(['viewer.recallSeed', 'viewer.recallSeed']); + }); +}); + +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/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 125126e078c..9db3d0b2471 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', @@ -136,7 +142,7 @@ const hotkey = ( category: HotkeyCategory, id: string, defaultKeys: string[], - title = formatHotkeyTitle(id) + { scope = categoryScopes[category], title = formatHotkeyTitle(id) }: { scope?: HotkeyScope; title?: string } = {} ): HotkeyDefinition => { const commandId = `${category}.${id}`; @@ -148,7 +154,7 @@ const hotkey = ( id: commandId, implemented: implemented.has(commandId), preventDefault: true, - scope: categoryScopes[category], + scope, title, }; }; @@ -168,6 +174,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'; /** @@ -228,8 +240,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']), @@ -292,13 +304,13 @@ 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'], { scope: selectedImageScope }), + hotkey('viewer', 'recallAll', ['a'], { scope: selectedImageScope }), + hotkey('viewer', 'recallSeed', ['s'], { scope: selectedImageScope }), + hotkey('viewer', 'recallPrompts', ['p'], { scope: selectedImageScope }), hotkey('viewer', 'remix', ['r']), - hotkey('viewer', 'useSize', ['d']), - hotkey('viewer', 'toggleMetadata', ['i'], 'Toggle Details'), + hotkey('viewer', 'useSize', ['d'], { scope: selectedImageScope }), + hotkey('viewer', 'toggleMetadata', ['i'], { title: 'Toggle Details' }), hotkey('gallery', 'selectAllOnPage', ['mod+a']), hotkey('gallery', 'clearSelection', ['esc']), hotkey('gallery', 'galleryNavUp', ['up']), 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/keys.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/keys.ts index 30d13da36d6..74b9ed9f79f 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/keys.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/keys.ts @@ -1,3 +1,5 @@ +import { matchKeybindingPress, parseKeybinding } from 'tinykeys'; + const MODIFIER_ORDER = ['mod', 'ctrl', 'meta', 'shift', 'alt'] as const; const KEY_ALIASES: Record = { @@ -94,6 +96,16 @@ export const toTinykeysBinding = (hotkey: string): string => { return parts.map((part) => TINY_MODIFIER_ALIASES[part] ?? TINY_KEY_ALIASES[part] ?? part).join('+'); }; +/** + * Whether a press is this binding, matched exactly as the hotkey runtime's tinykeys listener matches it, so a local + * handler and the window runtime agree on every press. + */ +export const isHotkeyPress = (event: KeyboardEvent, hotkey: string): boolean => { + const binding = toTinykeysBinding(hotkey); + const [press] = binding ? parseKeybinding(binding) : []; + return press !== undefined && matchKeybindingPress(event, press); +}; + /** The `mod` key's platform name; a bare modifier normalizes to nothing, so use this rather than formatting 'mod'. */ export const MOD_KEY_LABEL = IS_MAC_OS ? 'cmd' : 'ctrl'; 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..8a52b280b28 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.browser.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { HotkeyContext, RegisteredHotkey } from './types'; + +import { firstPartyHotkeyCatalog } from './catalog'; +import { IS_MAC_OS } from './keys'; +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'); + }); +}); + +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 513c220419e..f40287d1dbb 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/resolve.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { RegisteredHotkey } from './types'; +import type { HotkeyDefinition, RegisteredHotkey } from './types'; import { applyCustomHotkeys, resolveHotkey, toPlatformHotkey } from './resolve'; @@ -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', }; @@ -283,6 +283,36 @@ describe('resolveHotkey', () => { ).toBe('modal'); }); + it('gives a key to the user’s own binding over a default sharing its scope priority', () => { + const galleryContext = { ...context, activeInstanceId: 'gallery-1', activeWidgetTypeId: 'gallery' } as const; + const definition = (id: string, defaultKeys: string[], scope: HotkeyDefinition['scope']): HotkeyDefinition => ({ + category: 'app', + commandId: id, + defaultKeys, + id, + implemented: true, + scope, + title: id, + }); + // Catalog order: the default binding comes first, as a first-party hotkey precedes a later one on the same key. + const recallSeed = applyCustomHotkeys( + definition('recall', ['s'], { kind: 'widget', typeIds: ['preview', 'gallery'] }), + {} + ); + const starredOnly = applyCustomHotkeys(definition('starred', [], { kind: 'widget', typeIds: ['gallery'] }), { + starred: ['s'], + }); + const starredByDefault = applyCustomHotkeys({ ...starredOnly, defaultKeys: ['s'] }, {}); + const resolve = (hotkeys: RegisteredHotkey[]) => + resolveHotkey({ context: galleryContext, event, hotkeys, matchedKey: 's' })?.commandId; + + expect(resolve([recallSeed, starredOnly])).toBe('starred'); + expect(resolve([recallSeed, starredByDefault])).toBe('recall'); + // Scope priority still comes first: a custom binding on a less specific scope does not jump ahead. + const customGlobal = applyCustomHotkeys(definition('global', [], { kind: 'global' }), { global: ['s'] }); + expect(resolve([customGlobal, recallSeed])).toBe('recall'); + }); + 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 0a472858e46..5419a5d4c63 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') { @@ -41,13 +41,38 @@ 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 -): Hotkey & { keys: string[] } => ({ - ...hotkey, - keys: (customHotkeys[hotkey.id] ?? hotkey.defaultKeys).map(normalizeHotkeyString).filter(Boolean), -}); +): Hotkey & { customized: boolean; keys: string[] } => { + const customKeys = customHotkeys[hotkey.id]; + + return { + ...hotkey, + customized: customKeys !== undefined, + keys: (customKeys ?? hotkey.defaultKeys).map(normalizeHotkeyString).filter(Boolean), + }; +}; + +/** + * An open menu or listbox owns the unmodified presses inside it: arrows, Enter, Escape, Space and typeahead, Shift + * included because it types capitals. Zag prevents the default of a typed letter but lets it bubble, and a portaled + * popup sits outside every widget's DOM, so the press would otherwise resolve against whichever widget holds focus. + * A Ctrl, Meta or Alt chord is never popup input and resolves as usual. + */ +const isPopupOwnedPress = (event: KeyboardEvent): boolean => + !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 = ({ context, @@ -60,6 +85,10 @@ export const resolveHotkey = ({ hotkeys: RegisteredHotkey[]; matchedKey: string; }): RegisteredHotkey | null => { + if (isPopupOwnedPress(event)) { + return null; + } + const pressed = toPlatformHotkey(matchedKey); const isEditable = isEditableHotkeyTarget(event.target); @@ -67,9 +96,12 @@ 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) - .sort((left, right) => right.priority - left.priority)[0]?.hotkey ?? null + .sort( + (left, right) => right.priority - left.priority || customizedRank(right.hotkey) - customizedRank(left.hotkey) + )[0]?.hotkey ?? null ); }; 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..acc89653046 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 { @@ -24,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; @@ -31,6 +34,11 @@ export interface HotkeyDefinition { export interface RegisteredHotkey extends HotkeyDefinition { keys: string[]; + /** + * `keys` are the user's own binding rather than `defaultKeys`. At equal scope priority a custom binding wins the + * key over a default one, so a saved binding does not go dead when a default later takes its key. + */ + customized?: boolean; } export interface HotkeyContext { diff --git a/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.browser.test.tsx new file mode 100644 index 00000000000..255ea0d991b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.browser.test.tsx @@ -0,0 +1,83 @@ +import type * as projectsApi from '@workbench/projects/api'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { system } from '@theme/system'; +import { createInstance } from 'i18next'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; + +import { HotkeysSettingsSection } from './HotkeysSettingsSection'; +import { DEFAULT_PREFERENCES, patchWorkbenchPreferences } from './store'; + +vi.mock('@workbench/projects/api', async (importOriginal) => ({ + ...(await importOriginal()), + setClientStateValue: async () => {}, +})); + +const translations = await fetch('/locales/en.json').then((response) => response.json()); +const i18n = createInstance(); +await i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: translations } }, + interpolation: { escapeValue: false }, +}); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let host: HTMLDivElement; +let root: Root; + +const render = () => + act(() => { + root.render( + + + + + + ); + }); + +const search = () => page.getByRole('textbox', { name: i18n.t('hotkeys.searchPlaceholder'), exact: true }); +const conflictText = (title: string) => page.getByText(i18n.t('hotkeys.conflictsWith', { title }), { exact: true }); + +beforeEach(async () => { + await page.viewport(1280, 900); + accountLifecycle.activate('hotkeys-settings-test'); + await patchWorkbenchPreferences(DEFAULT_PREFERENCES); + host = document.createElement('div'); + host.style.height = '800px'; + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + accountLifecycle.invalidate(); +}); + +describe('HotkeysSettingsSection conflicts', () => { + // Recall Seed answers in Preview and Gallery. A Gallery binding on its key meets it through the second of those + // widget types; a Canvas binding on the same key meets neither. + it('flags a binding that shares its key with a multi-widget shortcut on any of that shortcut’s widgets', async () => { + await patchWorkbenchPreferences({ + customHotkeys: { 'canvas.selectBrushTool': ['s'], 'gallery.toggleStarredOnly': ['s'] }, + }); + await render(); + + await act(() => search().fill('starred')); + await expect.element(page.getByText('Toggle Starred Only', { exact: true })).toBeVisible(); + await expect.element(conflictText('Recall Seed')).toBeVisible(); + + await act(() => search().fill('brush')); + await expect.element(page.getByText('Select Brush Tool', { exact: true })).toBeVisible(); + await expect.element(conflictText('Recall Seed')).not.toBeInTheDocument(); + await expect.element(page.getByText(/^Conflicts with /)).not.toBeInTheDocument(); + }); +}); 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') { 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/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/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 = () => { ({ 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} 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/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..2900a6d1894 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 ); } @@ -367,6 +369,7 @@ export const ControlLayerSettings = ({ engine, layer, onOperationStarted }: Cont defaultValue={CONTROL_ADAPTER_DEFAULTS[adapter.kind].weight} disabled={editingLocked} formatValue={formatWeight} + hint="controlLayerWeight" inputMax={CONTROL_WEIGHT_BOUNDS.inputMax} inputMin={weightInputMin} label={t('widgets.layers.control.weight')} @@ -377,7 +380,7 @@ export const ControlLayerSettings = ({ engine, layer, onOperationStarted }: Cont onChange={handleWeightChange} onChangeEnd={handleWeightChangeEnd} /> - + { 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}