From b25ae3332450efe3f633fca0e6030c2994fb1bdf Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 18 Oct 2026 00:33:12 -0600 Subject: [PATCH] feat(ui): add a New project dialog to the Launchpad Choose a start, model, size, start image (upload, gallery or clipboard), library workflow and name before the editor opens; a one-shot request seeds the new draft. Lazy-load the editor's workflow library and publication dialogs so the shared library UI adds no editor boot request. --- .../Users Guide/02.Projects/introduction.mdx | 12 +- .../performance/architecture-baseline.json | 12 +- .../webv2/performance/browser-baseline.json | 113 +-- .../frontend/webv2/public/locales/en.json | 34 +- .../src/architecture/featureInterfaces.ts | 13 +- .../webv2/src/features/gallery/picker.ts | 3 + .../webv2/src/features/generation/settings.ts | 2 + .../features/workflow/core/workflowSource.ts | 20 + .../webv2/src/features/workflow/launchpad.tsx | 185 ++++ .../workflow/ui/WorkflowWidgetChrome.tsx | 42 +- .../ui/library/LibraryCopyChoiceDialog.tsx | 2 +- .../ui/library/ProjectWorkflowsView.tsx | 7 +- .../ui/library/WorkflowLibraryCard.tsx | 57 +- .../ui/library/WorkflowLibraryDialog.tsx | 5 +- .../ui/library/WorkflowLibraryGrid.tsx | 2 +- .../WorkflowPublicationHost.browser.test.tsx | 35 +- .../ui/library/WorkflowPublicationHost.tsx | 2 +- .../ui/library/projectWorkflowEntries.ts | 16 +- .../ui/library/useOpenLibraryWorkflow.ts | 3 +- .../features/workflow/ui/workflowUiStore.ts | 11 +- .../platform/browser/clipboardImage.test.ts | 59 ++ .../src/platform/browser/clipboardImage.ts | 39 + ...orkbenchSessionController.browser.test.tsx | 179 +++- .../workbench/WorkbenchSessionController.tsx | 46 + .../launchpad/projects/NewProjectButton.tsx | 104 +- .../NewProjectDialog.browser.test.tsx | 544 ++++++++++ .../launchpad/projects/NewProjectDialog.tsx | 933 ++++++++++++++++++ .../projects/newProjectDraft.test.ts | 243 +++++ .../launchpad/projects/newProjectDraft.ts | 261 +++++ .../webv2/src/workbench/projects/README.md | 2 +- .../projects/canvasStartImage.test.ts | 56 ++ .../workbench/projects/canvasStartImage.ts | 40 + .../projects/newProjectRequest.test.ts | 47 + .../workbench/projects/newProjectRequest.ts | 45 + .../webv2/src/workbench/settings/contracts.ts | 5 + .../src/workbench/settings/store.test.ts | 27 + .../webv2/src/workbench/settings/store.ts | 17 +- .../canvas/CanvasWidgetView.browser.test.tsx | 62 +- .../widgets/canvas/CanvasWidgetView.tsx | 49 +- .../widgets/canvas/canvasClipboard.test.ts | 56 +- .../widgets/canvas/canvasClipboard.ts | 37 +- invokeai/frontend/webv2/vite.config.mts | 16 + 42 files changed, 3144 insertions(+), 299 deletions(-) create mode 100644 invokeai/frontend/webv2/src/features/workflow/core/workflowSource.ts create mode 100644 invokeai/frontend/webv2/src/features/workflow/launchpad.tsx create mode 100644 invokeai/frontend/webv2/src/platform/browser/clipboardImage.test.ts create mode 100644 invokeai/frontend/webv2/src/platform/browser/clipboardImage.ts create mode 100644 invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.ts create mode 100644 invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.ts create mode 100644 invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.ts diff --git a/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx b/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx index ed5b05f6d33..009ec45a34a 100644 --- a/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx +++ b/docs/src/content/docs/Users Guide/02.Projects/introduction.mdx @@ -40,7 +40,7 @@ Undo history is not saved. It lasts only while the project is open in your brows The **Home** page is the first thing you see when Invoke opens. It shows the project you edited most recently under **Pick up where you left off**, a row of **Recent projects**, and tiles for starting something new. -To create a project, click **New project**. The arrow beside it (**Choose how to start**) offers starting points that open the project with a layout suited to the task: +To create a project, click **New project**. The dialog that opens lists the starting points under **Start with**; each opens the project with a layout suited to the task: | Start | Opens with | | --- | --- | @@ -50,7 +50,15 @@ To create a project, click **New project**. The arrow beside it (**Choose how to | **Generate a video** | The Video layout | | **Build a workflow** | The Automate layout, for the workflow editor | -The starting point only chooses the initial layout. Every tool remains available, and you can [rearrange the workspace](/users-guide/workbench/introduction/) at any time. +The starting point chooses the initial layout, and the dialog takes the first few choices with it: + +- **Generate from text** and **Edit on canvas** ask for the **Model** and the **Size** (aspect ratio chips or exact width and height). The project opens with those settings in place. +- **Edit on canvas** can also **Start from** an **Image**: choose one from the gallery, upload a file, or take it **From clipboard**. The canvas opens sized to the image, with the image as its first layer. +- **Build a workflow** opens with a **Blank workflow**, or with a workflow picked from the library's **Browse** and **Yours** tabs. +- The **Name** field names the project; leave it empty to keep the default name. +- **File (.invk)…** under **From** imports a project file instead. + +The Home tiles under **Start something** skip the dialog and open the start straight away. Every tool remains available afterwards, and you can [rearrange the workspace](/users-guide/workbench/introduction/) at any time. New projects are called **Project Name #1**, **Project Name #2** and so on. Give yours a meaningful name early (see [Renaming](#renaming-duplicating-and-deleting)), because the name also heads the project's boards in the gallery. diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 8d2183f5cc8..1d07659099d 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -179,6 +179,8 @@ "source:src/features/nodes/data/transport.ts", "source:src/features/nodes/index.ts", "source:src/features/nodes/ui/NodesPage.tsx", + "source:src/features/workflow/ui/MenuActionItem.tsx", + "source:src/platform/browser/clipboardImage.ts", "source:src/platform/browser/downloadBlob.ts", "source:src/platform/browser/imeComposition.ts", "source:src/platform/browser/knownBrowserIssues.ts", @@ -322,6 +324,7 @@ "source:src/workbench/projects/ids.ts", "source:src/workbench/projects/invk/format.ts", "source:src/workbench/projects/library.ts", + "source:src/workbench/projects/newProjectRequest.ts", "source:src/workbench/projects/openProjects.ts", "source:src/workbench/projects/projectAssets.ts", "source:src/workbench/projects/projectFile.ts", @@ -356,8 +359,8 @@ "largestAssetRawBytes": 752421, "otherAssetRawBytes": 0, "ownedRawBytes": 151806, - "requestCount": 72, - "scriptRequestCount": 56, + "requestCount": 71, + "scriptRequestCount": 55, "sourceOwners": [ "package:@ark-ui/react", "package:@chakra-ui/react", @@ -702,12 +705,15 @@ "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", "source:src/features/workflow/core/workflowJson.ts", + "source:src/features/workflow/core/workflowSource.ts", "source:src/features/workflow/data/templates.ts", "source:src/features/workflow/react.ts", + "source:src/features/workflow/ui/MenuActionItem.tsx", "source:src/features/workflow/ui/WorkflowUiContext.tsx", "source:src/features/workflow/ui/workflowUiStore.ts", "source:src/features/workflow/utility.ts", "source:src/features/workflow/widget.ts", + "source:src/platform/browser/clipboardImage.ts", "source:src/platform/browser/downloadBlob.ts", "source:src/platform/browser/imeComposition.ts", "source:src/platform/browser/knownBrowserIssues.ts", @@ -934,6 +940,7 @@ "source:src/workbench/projectWorkflows.ts", "source:src/workbench/projects/accountOwnedWorkbenchDatabase.ts", "source:src/workbench/projects/api.ts", + "source:src/workbench/projects/canvasStartImage.ts", "source:src/workbench/projects/components/OpenProjectDialog.tsx", "source:src/workbench/projects/components/ProjectFileOptionsProvider.tsx", "source:src/workbench/projects/components/index.ts", @@ -945,6 +952,7 @@ "source:src/workbench/projects/indexedDbDraftStore.ts", "source:src/workbench/projects/invk/format.ts", "source:src/workbench/projects/library.ts", + "source:src/workbench/projects/newProjectRequest.ts", "source:src/workbench/projects/openProjectBroker.ts", "source:src/workbench/projects/openProjects.ts", "source:src/workbench/projects/projectAssets.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 718797b8620..c9530e0f8b9 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -31,9 +31,9 @@ "imageRawBytes": 0, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 30, + "requestCount": 29, "scriptRawBytes": 1852857, - "scriptRequestCount": 26, + "scriptRequestCount": 25, "totalRawBytes": 2090003 }, "routeReadyMedianMs": 266.79999999701977, @@ -69,9 +69,9 @@ "imageRawBytes": 0, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 30, + "requestCount": 29, "scriptRawBytes": 1852857, - "scriptRequestCount": 26, + "scriptRequestCount": 25, "totalRawBytes": 2090003 }, "routeReadyMedianMs": 278.09999999403954, @@ -107,9 +107,9 @@ "imageRawBytes": 44398, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 88, + "requestCount": 87, "scriptRawBytes": 4131338, - "scriptRequestCount": 83, + "scriptRequestCount": 82, "totalRawBytes": 4412882 }, "routeReadyMedianMs": 443.29999999701977, @@ -145,9 +145,9 @@ "imageRawBytes": 44398, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 88, + "requestCount": 87, "scriptRawBytes": 4131338, - "scriptRequestCount": 83, + "scriptRequestCount": 82, "totalRawBytes": 4412882 }, "routeReadyMedianMs": 442.79999999701977, @@ -183,9 +183,9 @@ "imageRawBytes": 44398, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 97, + "requestCount": 96, "scriptRawBytes": 4647483, - "scriptRequestCount": 92, + "scriptRequestCount": 91, "totalRawBytes": 4929027 }, "routeReadyMedianMs": 1204.2999999970198, @@ -221,9 +221,9 @@ "imageRawBytes": 44398, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 97, + "requestCount": 96, "scriptRawBytes": 4647483, - "scriptRequestCount": 92, + "scriptRequestCount": 91, "totalRawBytes": 4929027 }, "routeReadyMedianMs": 1190.8999999910593, @@ -237,10 +237,10 @@ "imageRawBytes": 0, "largestAssetRawBytes": 201666, "otherRawBytes": 0, - "requestCount": 7, - "scriptRawBytes": 443711, - "scriptRequestCount": 6, - "totalRawBytes": 459124 + "requestCount": 8, + "scriptRawBytes": 448320, + "scriptRequestCount": 7, + "totalRawBytes": 463733 }, "domContentLoadedMedianMs": 130.79999999701977, "id": "editor-workflow", @@ -275,10 +275,10 @@ "imageRawBytes": 0, "largestAssetRawBytes": 201666, "otherRawBytes": 0, - "requestCount": 7, - "scriptRawBytes": 443711, - "scriptRequestCount": 6, - "totalRawBytes": 459124 + "requestCount": 8, + "scriptRawBytes": 448320, + "scriptRequestCount": 7, + "totalRawBytes": 463733 }, "domContentLoadedMedianMs": 131, "id": "editor-workflow", @@ -335,9 +335,9 @@ "imageRawBytes": 44398, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 88, + "requestCount": 87, "scriptRawBytes": 4131338, - "scriptRequestCount": 83, + "scriptRequestCount": 82, "totalRawBytes": 4412882 }, "routeReadyMedianMs": 1437.8999999910593, @@ -373,9 +373,9 @@ "imageRawBytes": 44398, "largestAssetRawBytes": 752421, "otherRawBytes": 211323, - "requestCount": 88, + "requestCount": 87, "scriptRawBytes": 4131338, - "scriptRequestCount": 83, + "scriptRequestCount": 82, "totalRawBytes": 4412882 }, "routeReadyMedianMs": 1443.8999999910593, @@ -590,6 +590,8 @@ "source:src/features/queue/data/submissionApi.ts", "source:src/features/queue/launchpad.ts", "source:src/features/queue/ui/launchpad/QueueStatusBand.tsx", + "source:src/features/workflow/ui/MenuActionItem.tsx", + "source:src/platform/browser/clipboardImage.ts", "source:src/platform/browser/downloadBlob.ts", "source:src/platform/browser/imeComposition.ts", "source:src/platform/browser/knownBrowserIssues.ts", @@ -734,6 +736,7 @@ "source:src/workbench/projects/ids.ts", "source:src/workbench/projects/invk/format.ts", "source:src/workbench/projects/library.ts", + "source:src/workbench/projects/newProjectRequest.ts", "source:src/workbench/projects/openProjects.ts", "source:src/workbench/projects/projectAssets.ts", "source:src/workbench/projects/projectFile.ts", @@ -1122,7 +1125,6 @@ "source:src/features/models/ui/ModelInstallRuntime.tsx", "source:src/features/models/ui/ModelsPage.tsx", "source:src/features/models/ui/ModelsUiContext.tsx", - "source:src/features/models/ui/add-models/starterModelInstallSources.ts", "source:src/features/models/ui/add-models/useInstallActions.ts", "source:src/features/models/ui/components/ModelSelect.tsx", "source:src/features/models/ui/uiStore.ts", @@ -1204,13 +1206,11 @@ "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", - "source:src/features/workflow/core/libraryTags.ts", - "source:src/features/workflow/core/modelRequirements.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", "source:src/features/workflow/core/workflowJson.ts", + "source:src/features/workflow/core/workflowSource.ts", "source:src/features/workflow/data/api.ts", - "source:src/features/workflow/data/libraryBrowseStore.ts", "source:src/features/workflow/data/libraryCache.ts", "source:src/features/workflow/data/publication.ts", "source:src/features/workflow/data/savedWorkflowQueries.ts", @@ -1226,26 +1226,14 @@ "source:src/features/workflow/ui/editor/flowInstanceStore.ts", "source:src/features/workflow/ui/editor/workflowViewportStore.ts", "source:src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx", - "source:src/features/workflow/ui/library/ProjectWorkflowsView.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryCard.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryDialog.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryGrid.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryTagChips.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx", - "source:src/features/workflow/ui/library/WorkflowPublicationHost.tsx", - "source:src/features/workflow/ui/library/WorkflowRequirementsList.tsx", - "source:src/features/workflow/ui/library/libraryPreviewSource.ts", - "source:src/features/workflow/ui/library/projectWorkflowEntries.ts", - "source:src/features/workflow/ui/library/relativeTime.ts", "source:src/features/workflow/ui/library/useOpenLibraryWorkflow.ts", - "source:src/features/workflow/ui/library/useWorkflowPublication.ts", "source:src/features/workflow/ui/pendingLibraryWorkflowLoadRuntime.ts", "source:src/features/workflow/ui/useProjectGraphCommands.ts", "source:src/features/workflow/ui/workflowTransfer.ts", "source:src/features/workflow/ui/workflowUiStore.ts", "source:src/features/workflow/utility.ts", "source:src/features/workflow/widget.ts", + "source:src/platform/browser/clipboardImage.ts", "source:src/platform/browser/downloadBlob.ts", "source:src/platform/browser/imeComposition.ts", "source:src/platform/browser/knownBrowserIssues.ts", @@ -1640,6 +1628,7 @@ "source:src/workbench/projectWorkflows.ts", "source:src/workbench/projects/accountOwnedWorkbenchDatabase.ts", "source:src/workbench/projects/api.ts", + "source:src/workbench/projects/canvasStartImage.ts", "source:src/workbench/projects/components/OpenProjectDialog.tsx", "source:src/workbench/projects/components/ProjectFileOptionsProvider.tsx", "source:src/workbench/projects/components/index.ts", @@ -1651,6 +1640,7 @@ "source:src/workbench/projects/indexedDbDraftStore.ts", "source:src/workbench/projects/invk/format.ts", "source:src/workbench/projects/library.ts", + "source:src/workbench/projects/newProjectRequest.ts", "source:src/workbench/projects/openProjectBroker.ts", "source:src/workbench/projects/openProjects.ts", "source:src/workbench/projects/projectAssets.ts", @@ -2212,7 +2202,6 @@ "source:src/features/models/ui/ModelInstallRuntime.tsx", "source:src/features/models/ui/ModelsPage.tsx", "source:src/features/models/ui/ModelsUiContext.tsx", - "source:src/features/models/ui/add-models/starterModelInstallSources.ts", "source:src/features/models/ui/add-models/useInstallActions.ts", "source:src/features/models/ui/components/ModelSelect.tsx", "source:src/features/models/ui/uiStore.ts", @@ -2296,13 +2285,11 @@ "source:src/features/workflow/core/formFieldSettings.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/layerWorkflow.ts", - "source:src/features/workflow/core/libraryTags.ts", - "source:src/features/workflow/core/modelRequirements.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", "source:src/features/workflow/core/workflowJson.ts", + "source:src/features/workflow/core/workflowSource.ts", "source:src/features/workflow/data/api.ts", - "source:src/features/workflow/data/libraryBrowseStore.ts", "source:src/features/workflow/data/libraryCache.ts", "source:src/features/workflow/data/publication.ts", "source:src/features/workflow/data/savedWorkflowQueries.ts", @@ -2318,26 +2305,14 @@ "source:src/features/workflow/ui/editor/flowInstanceStore.ts", "source:src/features/workflow/ui/editor/workflowViewportStore.ts", "source:src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx", - "source:src/features/workflow/ui/library/ProjectWorkflowsView.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryCard.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryDialog.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryGrid.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryTagChips.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx", - "source:src/features/workflow/ui/library/WorkflowPublicationHost.tsx", - "source:src/features/workflow/ui/library/WorkflowRequirementsList.tsx", - "source:src/features/workflow/ui/library/libraryPreviewSource.ts", - "source:src/features/workflow/ui/library/projectWorkflowEntries.ts", - "source:src/features/workflow/ui/library/relativeTime.ts", "source:src/features/workflow/ui/library/useOpenLibraryWorkflow.ts", - "source:src/features/workflow/ui/library/useWorkflowPublication.ts", "source:src/features/workflow/ui/pendingLibraryWorkflowLoadRuntime.ts", "source:src/features/workflow/ui/useProjectGraphCommands.ts", "source:src/features/workflow/ui/workflowTransfer.ts", "source:src/features/workflow/ui/workflowUiStore.ts", "source:src/features/workflow/utility.ts", "source:src/features/workflow/widget.ts", + "source:src/platform/browser/clipboardImage.ts", "source:src/platform/browser/downloadBlob.ts", "source:src/platform/browser/imeComposition.ts", "source:src/platform/browser/knownBrowserIssues.ts", @@ -2741,6 +2716,7 @@ "source:src/workbench/projectWorkflows.ts", "source:src/workbench/projects/accountOwnedWorkbenchDatabase.ts", "source:src/workbench/projects/api.ts", + "source:src/workbench/projects/canvasStartImage.ts", "source:src/workbench/projects/components/OpenProjectDialog.tsx", "source:src/workbench/projects/components/ProjectFileOptionsProvider.tsx", "source:src/workbench/projects/components/index.ts", @@ -2752,6 +2728,7 @@ "source:src/workbench/projects/indexedDbDraftStore.ts", "source:src/workbench/projects/invk/format.ts", "source:src/workbench/projects/library.ts", + "source:src/workbench/projects/newProjectRequest.ts", "source:src/workbench/projects/openProjectBroker.ts", "source:src/workbench/projects/openProjects.ts", "source:src/workbench/projects/projectAssets.ts", @@ -3461,7 +3438,6 @@ "source:src/features/models/ui/ModelInstallRuntime.tsx", "source:src/features/models/ui/ModelsPage.tsx", "source:src/features/models/ui/ModelsUiContext.tsx", - "source:src/features/models/ui/add-models/starterModelInstallSources.ts", "source:src/features/models/ui/add-models/useInstallActions.ts", "source:src/features/models/ui/components/ModelSelect.tsx", "source:src/features/models/ui/uiStore.ts", @@ -3569,14 +3545,12 @@ "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/graphLayout.ts", "source:src/features/workflow/core/graphToDocument.ts", - "source:src/features/workflow/core/libraryTags.ts", - "source:src/features/workflow/core/modelRequirements.ts", "source:src/features/workflow/core/outputFields.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", "source:src/features/workflow/core/workflowJson.ts", + "source:src/features/workflow/core/workflowSource.ts", "source:src/features/workflow/data/api.ts", - "source:src/features/workflow/data/libraryBrowseStore.ts", "source:src/features/workflow/data/libraryCache.ts", "source:src/features/workflow/data/publication.ts", "source:src/features/workflow/data/savedWorkflowFieldUtils.ts", @@ -3626,18 +3600,6 @@ "source:src/features/workflow/ui/graph-preview/nodeSummaries.ts", "source:src/features/workflow/ui/implementation.ts", "source:src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx", - "source:src/features/workflow/ui/library/ProjectWorkflowsView.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryCard.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryDetailPanel.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryDialog.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryGrid.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryTagChips.tsx", - "source:src/features/workflow/ui/library/WorkflowLibraryThumbnail.tsx", - "source:src/features/workflow/ui/library/WorkflowPublicationHost.tsx", - "source:src/features/workflow/ui/library/WorkflowRequirementsList.tsx", - "source:src/features/workflow/ui/library/libraryPreviewSource.ts", - "source:src/features/workflow/ui/library/projectWorkflowEntries.ts", - "source:src/features/workflow/ui/library/relativeTime.ts", "source:src/features/workflow/ui/library/useOpenLibraryWorkflow.ts", "source:src/features/workflow/ui/library/useWorkflowPublication.ts", "source:src/features/workflow/ui/linear/FormBuilderTab.tsx", @@ -3660,6 +3622,7 @@ "source:src/features/workflow/ui/workflowUiStore.ts", "source:src/features/workflow/utility.ts", "source:src/features/workflow/widget.ts", + "source:src/platform/browser/clipboardImage.ts", "source:src/platform/browser/downloadBlob.ts", "source:src/platform/browser/imeComposition.ts", "source:src/platform/browser/knownBrowserIssues.ts", @@ -4055,6 +4018,7 @@ "source:src/workbench/projectWorkflows.ts", "source:src/workbench/projects/accountOwnedWorkbenchDatabase.ts", "source:src/workbench/projects/api.ts", + "source:src/workbench/projects/canvasStartImage.ts", "source:src/workbench/projects/components/OpenProjectDialog.tsx", "source:src/workbench/projects/components/ProjectFileOptionsProvider.tsx", "source:src/workbench/projects/components/index.ts", @@ -4066,6 +4030,7 @@ "source:src/workbench/projects/indexedDbDraftStore.ts", "source:src/workbench/projects/invk/format.ts", "source:src/workbench/projects/library.ts", + "source:src/workbench/projects/newProjectRequest.ts", "source:src/workbench/projects/openProjectBroker.ts", "source:src/workbench/projects/openProjects.ts", "source:src/workbench/projects/projectAssets.ts", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 5ed4de02fc2..2e996fddbba 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -530,7 +530,39 @@ "unsupportedVersion": "\"{{name}}\" was saved by a newer version of Invoke and cannot be opened here." }, "newProject": "New project", - "newProjectStart": "Choose how to start", + "newProjectDialog": { + "aspectRatio": "Aspect ratio", + "blankWorkflow": "Blank workflow", + "blankWorkflowDescription": "Start from an empty graph.", + "capabilitiesLoadFailed": "Model capabilities could not be loaded.", + "chooseImageOrBlank": "Choose an image or start blank", + "clipboardEmpty": "No image on the clipboard", + "create": "Create", + "from": "From", + "fromClipboard": "From clipboard", + "height": "Height", + "imageSizeHint": "The canvas will match the image.", + "importFile": "File (.invk)\u2026", + "loadFailed": "The New project dialog could not be loaded.", + "model": "Model", + "modelLoadFailed": "Models could not be loaded.", + "modelLoading": "Loading models\u2026", + "namePlaceholder": "Project Name", + "noModelsDescription": "Install a main image model to generate from text or edit on canvas.", + "noModelsTitle": "No compatible models installed", + "openModelManager": "Open model manager", + "size": "Size", + "sizeValue": "{{width}} \u00d7 {{height}} px", + "startBlank": "Blank", + "startFrom": "Start from", + "startImage": "Image", + "startWith": "Start with", + "swapDimensions": "Swap width and height", + "title": "New project", + "upscaleHint": "You'll pick the image to upscale once the project opens.", + "videoHint": "Choose a video model and clip settings once the project opens.", + "width": "Width" + }, "noSavedProjects": "No saved projects yet.", "noSavedProjectsDescription": "Create your first project to start generating.", "noSearchMatches": "No projects match “{{search}}”.", diff --git a/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts b/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts index 9405bcfd449..7ddb4b57445 100644 --- a/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts +++ b/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts @@ -38,5 +38,16 @@ export const FEATURE_PUBLIC_INTERFACES: Readonly + libraryWorkflowId.startsWith('default_'); + +/** Whether a source can be written to at all; the server still has the final word (ownership, deletion). */ +export const isUpdatableSource = (source: ProjectWorkflowSource | undefined): source is ProjectWorkflowSource => + source !== undefined && !isBundledLibraryWorkflowId(source.libraryWorkflowId); + +/** The project copies of one library template, in collection order. */ +export const findProjectCopiesOf = ( + workflows: readonly ProjectWorkflowEntry[], + libraryWorkflowId: string +): ProjectWorkflowEntry[] => workflows.filter((entry) => entry.source?.libraryWorkflowId === libraryWorkflowId); diff --git a/invokeai/frontend/webv2/src/features/workflow/launchpad.tsx b/invokeai/frontend/webv2/src/features/workflow/launchpad.tsx new file mode 100644 index 00000000000..c2832656fa4 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/launchpad.tsx @@ -0,0 +1,185 @@ +import type { WorkflowLibraryCategory, WorkflowLibraryListItem } from '@features/workflow/data/api'; +import type { WorkflowLibraryBrowseSnapshot } from '@features/workflow/data/libraryBrowseStore'; +import type { ChangeEvent, KeyboardEvent } from 'react'; + +import { HStack, Input, Stack } from '@chakra-ui/react'; +import { + ensureWorkflowLibraryBrowseLoaded, + getWorkflowLibraryBrowseSnapshot, + setWorkflowLibraryBrowseFilter, + useWorkflowLibraryBrowseSelector, +} from '@features/workflow/data/libraryBrowseStore'; +import { useMountEffect } from '@platform/react/useMountEffect'; +import { SegmentTabs, segmentTabsPanelId, segmentTabsTabId } from '@platform/ui/SegmentTabs'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { WorkflowLibraryGrid } from './ui/library/WorkflowLibraryGrid'; +import { WorkflowLibraryTagChips } from './ui/library/WorkflowLibraryTagChips'; + +/** + * The library as a single-select picker for the Launchpad's New-project dialog: search, Browse/Yours, tag chips and + * the card grid, without the editor dialog's actions, detail panel or This-project view. The browse store is shared + * with the editor dialog, so filters carry over between the two. + */ + +const SEARCH_DEBOUNCE_MS = 300; + +const TAB_ITEMS: ReadonlyArray<{ labelKey: string; value: WorkflowLibraryCategory }> = [ + { labelKey: 'workflowLibrary.browse', value: 'default' }, + { labelKey: 'workflowLibrary.yours', value: 'user' }, +]; + +/** Flat and shallow-comparable, so unrelated store patches do not re-render the picker. */ +const selectBrowseView = (snapshot: WorkflowLibraryBrowseSnapshot) => ({ + category: snapshot.filter.category, + entries: snapshot.entries, + error: snapshot.error, + status: snapshot.status, + tag: snapshot.filter.tag, + tagCounts: snapshot.tagCounts, +}); + +export interface WorkflowLibraryPickerProps { + selectedWorkflowId: string | null; + onSelect: (item: WorkflowLibraryListItem) => void; + /** A double-click or Enter on a card: the pick is final. */ + onOpen: (item: WorkflowLibraryListItem) => void; +} + +export const WorkflowLibraryPicker = ({ onOpen, onSelect, selectedWorkflowId }: WorkflowLibraryPickerProps) => { + const { t } = useTranslation(); + const { category, entries, error, status, tag, tagCounts } = useWorkflowLibraryBrowseSelector(selectBrowseView); + const [searchInput, setSearchInput] = useState(''); + const searchTimerRef = useRef | null>(null); + const tabsIdBase = useId(); + + // Start unfiltered (the store is shared with the editor dialog) and load the first page; an account without saved + // workflows starts on Browse, as the editor dialog does. + useMountEffect(() => { + if (getWorkflowLibraryBrowseSnapshot().filter.search) { + setWorkflowLibraryBrowseFilter({ search: '' }); + } + + void ensureWorkflowLibraryBrowseLoaded().then(() => { + const { filter, userTotal } = getWorkflowLibraryBrowseSnapshot(); + + if (userTotal === 0 && filter.category === 'user' && !filter.search) { + setWorkflowLibraryBrowseFilter({ category: 'default', tag: null }); + } + }); + + return () => { + if (searchTimerRef.current !== null) { + clearTimeout(searchTimerRef.current); + } + }; + }); + + const handleSearchChange = useCallback((event: ChangeEvent) => { + const { value } = event.currentTarget; + + setSearchInput(value); + + if (searchTimerRef.current !== null) { + clearTimeout(searchTimerRef.current); + } + + searchTimerRef.current = setTimeout(() => setWorkflowLibraryBrowseFilter({ search: value }), SEARCH_DEBOUNCE_MS); + }, []); + const handleTabChange = useCallback( + (value: WorkflowLibraryCategory) => { + // Chips belong to a category, so changing it clears the tag. + if (category !== value) { + setWorkflowLibraryBrowseFilter({ category: value, tag: null }); + } + }, + [category] + ); + const handleTagSelect = useCallback((nextTag: string | null) => setWorkflowLibraryBrowseFilter({ tag: nextTag }), []); + + const findItem = useCallback( + (workflowId: string) => + getWorkflowLibraryBrowseSnapshot().entries.find((entry) => entry.item.workflow_id === workflowId)?.item, + [] + ); + const handleSelect = useCallback( + (workflowId: string) => { + const item = findItem(workflowId); + + if (item) { + onSelect(item); + } + }, + [findItem, onSelect] + ); + const handleOpen = useCallback( + (workflowId: string) => { + const item = findItem(workflowId); + + if (item) { + onOpen(item); + } + }, + [findItem, onOpen] + ); + // Cards open on double-click; Enter on a focused card is its keyboard equivalent. + const handleGridKeyDown = useCallback( + (event: KeyboardEvent) => { + const workflowId = (event.target as HTMLElement).closest('[data-workflow-card]')?.dataset + .workflowCard; + + if (event.key === 'Enter' && workflowId) { + event.preventDefault(); + handleOpen(workflowId); + } + }, + [handleOpen] + ); + + const tabItems = useMemo(() => TAB_ITEMS.map((item) => ({ id: item.value, label: t(item.labelKey) })), [t]); + + return ( + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx index b61e4395b9f..49c4f76ae32 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx @@ -1,6 +1,7 @@ import type { InvocationTemplate, XYPosition } from '@features/workflow/contracts'; import { Box, HStack, Icon, Menu, Text } from '@chakra-ui/react'; +import { isUpdatableSource } from '@features/workflow/core/workflowSource'; import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; import { @@ -19,8 +20,10 @@ import { shouldAddForReturnLoopLinkage, parseWorkflowJson, } from '@features/workflow/utility'; +import { useExitPresence } from '@platform/react/useExitRetainedValue'; import { useMountEffect } from '@platform/react/useMountEffect'; import { Button, IconButton, RenameDialog, Tooltip } from '@platform/ui'; +import { Dialog } from '@platform/ui/Dialog'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { BookmarkIcon, @@ -32,7 +35,7 @@ import { RefreshCwIcon, TriangleAlertIcon, } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState, type ChangeEvent, type ElementType } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ChangeEvent, type ElementType } from 'react'; import { useTranslation } from 'react-i18next'; import type { WorkflowWidgetLabelProps, WorkflowWidgetViewProps } from './contracts'; @@ -43,9 +46,6 @@ import { CallSavedWorkflowSyncRuntime } from './editor/CallSavedWorkflowSyncRunt import { getWorkflowFlowInstance } from './editor/flowInstanceStore'; import { releaseWorkflowViewportsExcept } from './editor/workflowViewportStore'; import { LibraryCopyChoiceHost } from './library/LibraryCopyChoiceDialog'; -import { isUpdatableSource } from './library/projectWorkflowEntries'; -import { WorkflowLibraryDialog } from './library/WorkflowLibraryDialog'; -import { WorkflowPublicationHost } from './library/WorkflowPublicationHost'; import { PendingWorkflowLoader } from './PendingLibraryWorkflowLoader'; import { copyWorkflowJson, downloadWorkflowJson } from './workflowTransfer'; import { @@ -65,6 +65,20 @@ import { workflowUiStore, } from './workflowUiStore'; +// The library (its grid, detail panel and publication flows) loads the first time it opens rather than with the +// widget; the Launchpad's New-project dialog shares its grid, so neither route carries it at boot. +const LazyWorkflowLibraryDialog = lazy(() => + import('./library/WorkflowLibraryDialog').then((module) => ({ default: module.WorkflowLibraryDialog })) +); +// The publication dialogs load with the account's first publication intent and then stay (the flag lives in the +// store, not here: this host unmounts with the widget host, and an unanswered save must still be re-offered on +// returning to its workflow). +const LazyWorkflowPublicationHost = lazy(() => + import('./library/WorkflowPublicationHost').then((module) => ({ default: module.WorkflowPublicationHost })) +); +// Modal from the request onward: while the dialog's module is still on its way, shortcuts must already be off. +const PENDING_LIBRARY_DIALOG = ; + /** * Keep dialogs mounted here and drive them through workflowUiStore; manifest slots contribute label, quick * actions, and shared menu actions. @@ -306,6 +320,10 @@ export const WorkflowDialogHost = () => { const importRequestCount = workflowUiStore.useSelector((snapshot) => snapshot.importRequestCount); const isAddNodeOpen = workflowUiStore.useSelector((snapshot) => snapshot.isAddNodeOpen); const isLibraryOpen = workflowUiStore.useSelector((snapshot) => snapshot.isLibraryOpen); + // Mounted from the first open until its close animation ends, so it animates out and is not re-fetched. + const library = useExitPresence(isLibraryOpen); + const hasPublicationIntent = workflowUiStore.useSelector((snapshot) => snapshot.publicationIntent !== null); + const hasRequestedPublication = workflowUiStore.useSelector((snapshot) => snapshot.publicationHostRequested); const lastImportRequestRef = useRef(importRequestCount); // A rename request opens the dialog once, for the workflow it named, and only while that workflow is the active // one; closing records the request as handled. @@ -567,7 +585,15 @@ export const WorkflowDialogHost = () => { onAddNote={addNote} onOpenChange={setAddNodeOpen} /> - + {library.isMounted ? ( + + + + ) : null} { onClose={closeRename} onSubmit={submitRename} /> - + {hasRequestedPublication ? ( + + + + ) : null} ); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx index 36f45f0cd03..f04fbfe09c4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/LibraryCopyChoiceDialog.tsx @@ -1,6 +1,7 @@ import type { LibraryCopyChoiceRequest } from '@features/workflow/ui/workflowUiStore'; import { createListCollection, Portal, Stack } from '@chakra-ui/react'; +import { findProjectCopiesOf } from '@features/workflow/core/workflowSource'; import { useWorkflowProjectSelector, useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext'; import { clearLibraryCopyChoice, setWorkflowLibraryOpen, workflowUiStore } from '@features/workflow/ui/workflowUiStore'; import { useMountEffect } from '@platform/react/useMountEffect'; @@ -11,7 +12,6 @@ import { Select } from '@platform/ui/Select'; import { useCallback, useMemo, useRef, useState, type RefObject } from 'react'; import { useTranslation } from 'react-i18next'; -import { findProjectCopiesOf } from './projectWorkflowEntries'; import { useOpenLibraryWorkflow, type OpenLibraryWorkflow } from './useOpenLibraryWorkflow'; /** diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx index a06fc909d1a..7bbdde6d44d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/ProjectWorkflowsView.tsx @@ -1,6 +1,7 @@ import type { ProjectWorkflowEntry } from '@features/workflow/core/types'; import { Badge, Box, Flex, HStack, Icon, Image, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { isBundledLibraryWorkflowId, isUpdatableSource } from '@features/workflow/core/workflowSource'; import { useInvocationTemplatesSnapshot } from '@features/workflow/react'; import { MenuActionItem } from '@features/workflow/ui/MenuActionItem'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; @@ -26,11 +27,7 @@ import { import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - isBundledLibraryWorkflowId, - isUpdatableSource, - toProjectWorkflowLibraryEntries, -} from './projectWorkflowEntries'; +import { toProjectWorkflowLibraryEntries } from './projectWorkflowEntries'; import { formatRelativeTime } from './relativeTime'; import { getWorkflowLibraryCardId, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx index d6cc71de5e5..908c89f4f7a 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx @@ -1,6 +1,6 @@ import type { WorkflowLibraryEntry } from '@features/workflow/data/libraryBrowseStore'; -import { Badge, Box, Flex, HStack, Icon, Image, Skeleton, Stack, Text } from '@chakra-ui/react'; +import { Badge, Box, chakra, Flex, HStack, Icon, Image, Skeleton, Stack, Text } from '@chakra-ui/react'; import { getModelBaseLabel } from '@features/models'; import { IconButton } from '@platform/ui/Button'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; @@ -75,7 +75,8 @@ export interface WorkflowLibraryCardProps { isSelected: boolean; /** Models this workflow needs that are not installed; 0 hides the badge. */ missingCount: number; - onContextMenu: (workflowId: string, anchor: WorkflowCardMenuAnchor) => void; + /** Absent in pick-only surfaces: the card then has no actions menu and right-click is the browser's. */ + onContextMenu?: (workflowId: string, anchor: WorkflowCardMenuAnchor) => void; onOpen: (workflowId: string) => void; onSelect: (workflowId: string) => void; } @@ -106,7 +107,7 @@ export const WorkflowLibraryCard = ({ const rect = event.currentTarget.getBoundingClientRect(); const fromKeyboard = event.clientX === 0 && event.clientY === 0; - onContextMenu( + onContextMenu?.( workflowId, fromKeyboard ? { kind: 'point', workflowId, x: rect.left + 16, y: rect.top + 16 } @@ -117,7 +118,7 @@ export const WorkflowLibraryCard = ({ ); const handleMenuButton = useCallback( () => - onContextMenu(workflowId, { kind: 'trigger', triggerId: getWorkflowLibraryCardMenuId(workflowId), workflowId }), + onContextMenu?.(workflowId, { kind: 'trigger', triggerId: getWorkflowLibraryCardMenuId(workflowId), workflowId }), [onContextMenu, workflowId] ); const handleThumbnailError = useCallback(() => setHasThumbnailFailed(true), []); @@ -130,8 +131,7 @@ export const WorkflowLibraryCard = ({ // The menu button is a sibling of the card button, never nested in it: a button cannot hold another control. return ( - @@ -202,26 +203,28 @@ export const WorkflowLibraryCard = ({ ) : null} - - - - - - + + {onContextMenu ? ( + + + + + + ) : null} ); }; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx index 3afec94e576..a4268ff358e 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx @@ -88,9 +88,12 @@ type PreviewRequest = export const WorkflowLibraryDialog = ({ isOpen, + onExitComplete, onOpenChange, }: { isOpen: boolean; + /** After the close animation; a host that mounts the dialog lazily releases it here. */ + onExitComplete?: () => void; onOpenChange: (isOpen: boolean) => void; }) => { const { t } = useTranslation(); @@ -300,7 +303,7 @@ export const WorkflowLibraryDialog = ({ return ( <> - + diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx index b371534da1d..136ce161f21 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx @@ -25,7 +25,7 @@ export interface WorkflowLibraryGridProps { missingCounts?: ReadonlyMap; selectedWorkflowId: string | null; status: WorkflowLibraryBrowseSnapshot['status']; - onContextMenu: WorkflowLibraryCardProps['onContextMenu']; + onContextMenu?: WorkflowLibraryCardProps['onContextMenu']; onOpen: (workflowId: string) => void; onSelect: (workflowId: string) => void; } diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx index 4dc9e0a1319..a1422511a45 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.browser.test.tsx @@ -6,6 +6,7 @@ import { ChakraProvider } from '@chakra-ui/react'; import { WorkflowLibraryWriteRefusedError } from '@features/workflow/data/api'; import { WorkflowUiProvider } from '@features/workflow/ui/WorkflowUiContext'; import { requestWorkflowPublication, workflowUiStore } from '@features/workflow/ui/workflowUiStore'; +import { WorkflowDialogHost } from '@features/workflow/ui/WorkflowWidgetChrome'; import { createProjectGraph, serializeWorkflowJson } from '@features/workflow/utility'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -26,6 +27,14 @@ const api = vi.hoisted(() => ({ updateLibraryWorkflow: vi.fn(), })); +// The dialog host's other dialogs are not under test here; the publication host it mounts lazily is the real one. +vi.mock('@features/workflow/ui/editor/AddNodeDialog', () => ({ AddNodeDialog: () => null })); +vi.mock('@features/workflow/ui/editor/CallSavedWorkflowSyncRuntime', () => ({ + CallSavedWorkflowSyncRuntime: () => null, +})); +vi.mock('@features/workflow/ui/library/WorkflowLibraryDialog', () => ({ WorkflowLibraryDialog: () => null })); +vi.mock('@features/workflow/ui/PendingLibraryWorkflowLoader', () => ({ PendingWorkflowLoader: () => null })); + vi.mock('@features/workflow/data/api', async (importOriginal) => ({ ...(await importOriginal>()), ...api, @@ -177,11 +186,11 @@ describe('WorkflowPublicationHost', () => { await settleFrame(); }); - const renderHost = async (workflows: readonly ProjectWorkflowEntry[]) => { + const renderHost = async (workflows: readonly ProjectWorkflowEntry[], surface: 'host' | 'dialog-host' = 'host') => { project = createMutablePort(projectSnapshot(workflows)); // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop -- intentionally stable for this render lifetime const adapter = { - commands, + commands: { ...commands, addWorkflow: vi.fn(), editGraph: vi.fn() }, getProjectGraph: () => project.port.getSnapshot().projectGraph, notifications, project: project.port, @@ -193,7 +202,7 @@ describe('WorkflowPublicationHost', () => { - + {surface === 'host' ? : } @@ -489,6 +498,26 @@ describe('WorkflowPublicationHost', () => { expect(dialogs()).toHaveLength(0); }); + it('re-offers an unanswered save when the dialog host comes back for that workflow', async () => { + api.createLibraryWorkflowRecord.mockRejectedValueOnce(new Error('offline')); + + // Through the widget's dialog host, which loads the publication host lazily on the first intent. + await renderHost([UNLINKED], 'dialog-host'); + await request('save-as-new'); + await vi.waitFor(() => expect(saveDialog()).not.toBeNull()); + await submitSaveAsNew('Alpha'); + expect(dialogTitled('Library did not answer')?.textContent).toContain('(offline)'); + + // Leaving for a project without the Workflow widget unmounts the dialog host; coming back mounts a fresh one. + await act(() => root.unmount()); + root = createRoot(host); + await renderHost([UNLINKED], 'dialog-host'); + await flush(); + + await vi.waitFor(() => expect(dialogTitled('Library did not answer')?.textContent).toContain('(offline)')); + expect(api.createLibraryWorkflowRecord).toHaveBeenCalledTimes(1); + }); + it('links the copy and reports success once a retried save lands', async () => { api.createLibraryWorkflowRecord .mockRejectedValueOnce(new Error('offline')) diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx index 187b029bb03..339ec0b81cf 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowPublicationHost.tsx @@ -3,6 +3,7 @@ import type { WorkflowRecordDTO } from '@features/workflow/queries'; import type { FormEvent } from 'react'; import { chakra, Input, Portal, Stack, Text } from '@chakra-ui/react'; +import { isUpdatableSource } from '@features/workflow/core/workflowSource'; import { savedWorkflowDetailQueryOptions } from '@features/workflow/data/savedWorkflowQueries'; import { getLibraryWorkflowRecord } from '@features/workflow/queries'; import { useInvocationTemplatesSnapshot } from '@features/workflow/react'; @@ -29,7 +30,6 @@ import { DeferredGraphPreviewDialog, preloadGraphPreview, } from './libraryPreviewSource'; -import { isUpdatableSource } from './projectWorkflowEntries'; import { formatRelativeTime } from './relativeTime'; import { discardUnresolvedWorkflowPublication, diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts index 15f1c429961..9df3fe3aa94 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/projectWorkflowEntries.ts @@ -1,4 +1,4 @@ -import type { InvocationTemplates, ProjectWorkflowEntry, ProjectWorkflowSource } from '@features/workflow/core/types'; +import type { InvocationTemplates, ProjectWorkflowEntry } from '@features/workflow/core/types'; import type { WorkflowLibraryEntry } from '@features/workflow/data/libraryBrowseStore'; import { galleryImageUrls } from '@features/gallery/utility'; @@ -20,14 +20,6 @@ const cache = new WeakMap< { entry: ProjectWorkflowLibraryEntry; templates: InvocationTemplates | null } >(); -/** Bundled template ids are a server invariant (`default_` prefix); they are read-only whoever asks. */ -export const isBundledLibraryWorkflowId = (libraryWorkflowId: string): boolean => - libraryWorkflowId.startsWith('default_'); - -/** Whether a source can be written to at all; the server still has the final word (ownership, deletion). */ -export const isUpdatableSource = (source: ProjectWorkflowSource | undefined): source is ProjectWorkflowSource => - source !== undefined && !isBundledLibraryWorkflowId(source.libraryWorkflowId); - export const toProjectWorkflowLibraryEntry = ( projectWorkflow: ProjectWorkflowEntry, templates: InvocationTemplates | null @@ -72,9 +64,3 @@ export const toProjectWorkflowLibraryEntries = ( workflows: readonly ProjectWorkflowEntry[], templates: InvocationTemplates | null ): ProjectWorkflowLibraryEntry[] => workflows.map((workflow) => toProjectWorkflowLibraryEntry(workflow, templates)); - -/** The project copies of one library template, in collection order. */ -export const findProjectCopiesOf = ( - workflows: readonly ProjectWorkflowEntry[], - libraryWorkflowId: string -): ProjectWorkflowEntry[] => workflows.filter((entry) => entry.source?.libraryWorkflowId === libraryWorkflowId); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts index b2c3e05c2aa..3b788af83a8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/useOpenLibraryWorkflow.ts @@ -1,6 +1,7 @@ import type { ProjectWorkflowEntry } from '@features/workflow/core/types'; import type { LibraryOpenItem } from '@features/workflow/ui/workflowUiStore'; +import { findProjectCopiesOf } from '@features/workflow/core/workflowSource'; import { updateLoadedWorkflowNodes } from '@features/workflow/data/templates'; import { getLibraryWorkflowRecordCached, touchLibraryWorkflowOpenedAt } from '@features/workflow/queries'; import { requestWorkflowFitView } from '@features/workflow/ui/editor/flowInstanceStore'; @@ -16,8 +17,6 @@ import { getApiErrorMessage } from '@platform/transport/http'; import { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { findProjectCopiesOf } from './projectWorkflowEntries'; - /** * Opening a template always lands in the project's own collection. The first copy is added directly; once the * project holds one, the caller asks whether to open a copy, add another, or replace one with the library version. diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts b/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts index 60d78b07b27..54f90e17cb6 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/workflowUiStore.ts @@ -45,8 +45,14 @@ export interface WorkflowUiSnapshot { renameRequest: { requestId: number; workflowId: string } | null; /** A workflow a shell surface (command palette, an image's context menu) asked to load; consumed by the widget chrome. */ pendingWorkflowLoad: WorkflowLoadRequest | null; - /** A publication the always-mounted host should start a dialog for. */ + /** A publication the host should start a dialog for; the host loads with the first one and consumes it on mount. */ publicationIntent: WorkflowPublicationIntent | null; + /** + * A publication has been asked for this account: the lazily loaded host stays mounted whenever the dialog host is, + * so an unanswered save is re-offered on returning to its workflow even after the widget host went away and came + * back. Only the account reset clears it. + */ + publicationHostRequested: boolean; /** A template the project already holds copies of; the host asks whether to open, add, or replace a copy. */ libraryCopyChoice: LibraryCopyChoiceRequest | null; } @@ -92,6 +98,7 @@ const INITIAL_WORKFLOW_UI_SNAPSHOT: WorkflowUiSnapshot = { libraryTab: 'project', pendingWorkflowLoad: null, publicationIntent: null, + publicationHostRequested: false, libraryCopyChoice: null, }; @@ -154,7 +161,7 @@ export const clearLibraryCopyChoice = (requestId: number): void => { }; export const requestWorkflowPublication = (intent: WorkflowPublicationIntent): void => { - workflowUiStore.patchSnapshot({ publicationIntent: intent }); + workflowUiStore.patchSnapshot({ publicationHostRequested: true, publicationIntent: intent }); }; export const clearWorkflowPublicationIntent = (): void => { diff --git a/invokeai/frontend/webv2/src/platform/browser/clipboardImage.test.ts b/invokeai/frontend/webv2/src/platform/browser/clipboardImage.test.ts new file mode 100644 index 00000000000..7d1b2bd0f0e --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/browser/clipboardImage.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { readClipboardImage } from './clipboardImage'; + +const blob = (type: string): Blob => ({ type }) as Blob; + +/** A ClipboardItem stand-in exposing just what the reader touches. */ +const item = (types: string[], payload: Record = {}): ClipboardItem => + ({ + getType: (type: string) => Promise.resolve(payload[type] ?? blob(type)), + types, + }) as unknown as ClipboardItem; + +describe('readClipboardImage', () => { + it('returns null when the clipboard API is unavailable', async () => { + await expect(readClipboardImage({ clipboard: undefined })).resolves.toBeNull(); + }); + + it('returns null when permission is refused rather than throwing', async () => { + // A paste that cannot read the clipboard is an ordinary outcome — a + // defocused document refuses too — so it must not surface as an error. + const clipboard = { read: () => Promise.reject(new Error('NotAllowedError')) }; + await expect(readClipboardImage({ clipboard })).resolves.toBeNull(); + }); + + it('returns null when the clipboard holds no image', async () => { + const clipboard = { read: () => Promise.resolve([item(['text/plain'])]) }; + await expect(readClipboardImage({ clipboard })).resolves.toBeNull(); + }); + + it('reads a PNG off the clipboard', async () => { + const png = blob('image/png'); + const clipboard = { read: () => Promise.resolve([item(['image/png'], { 'image/png': png })]) }; + await expect(readClipboardImage({ clipboard })).resolves.toBe(png); + }); + + it('prefers PNG over lossy types when several are offered', async () => { + const png = blob('image/png'); + const jpeg = blob('image/jpeg'); + const clipboard = { + read: () => Promise.resolve([item(['image/jpeg', 'image/png'], { 'image/jpeg': jpeg, 'image/png': png })]), + }; + await expect(readClipboardImage({ clipboard })).resolves.toBe(png); + }); + + it('falls back to a lossy type when PNG is absent', async () => { + const jpeg = blob('image/jpeg'); + const clipboard = { read: () => Promise.resolve([item(['image/jpeg'], { 'image/jpeg': jpeg })]) }; + await expect(readClipboardImage({ clipboard })).resolves.toBe(jpeg); + }); + + it('scans past a non-image item to a later image', async () => { + const png = blob('image/png'); + const clipboard = { + read: () => Promise.resolve([item(['text/plain']), item(['image/png'], { 'image/png': png })]), + }; + await expect(readClipboardImage({ clipboard })).resolves.toBe(png); + }); +}); diff --git a/invokeai/frontend/webv2/src/platform/browser/clipboardImage.ts b/invokeai/frontend/webv2/src/platform/browser/clipboardImage.ts new file mode 100644 index 00000000000..bdcb743ad7a --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/browser/clipboardImage.ts @@ -0,0 +1,39 @@ +/** + * The clipboard's image, read without a canvas or widget in sight; inject `clipboard` for tests without real + * clipboard access. + */ + +type ClipboardReadLike = { read(): Promise }; + +export interface ReadClipboardImageDeps { + clipboard?: ClipboardReadLike; +} + +/** MIME types accepted from the clipboard, best first. */ +const IMAGE_TYPES = ['image/png', 'image/webp', 'image/jpeg'] as const; + +/** + * The first image on the clipboard, or `null` when it holds none. Returns + * `null` rather than throwing when the clipboard is unavailable or permission + * was refused — a paste that finds nothing is an ordinary outcome, not an error. + */ +export const readClipboardImage = async (deps: ReadClipboardImageDeps = {}): Promise => { + const clipboard = deps.clipboard ?? (globalThis.navigator?.clipboard as ClipboardReadLike | undefined); + if (!clipboard?.read) { + return null; + } + let items: readonly ClipboardItem[]; + try { + items = await clipboard.read(); + } catch { + // Permission refused, or the document is not focused. Nothing to paste. + return null; + } + for (const item of items) { + const type = IMAGE_TYPES.find((candidate) => item.types.includes(candidate)); + if (type) { + return item.getType(type); + } + } + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.browser.test.tsx index 57736e4b051..04c3404ac6e 100644 --- a/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.browser.test.tsx @@ -5,9 +5,16 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { WorkbenchSearch } from './projects/session'; const harness = vi.hoisted(() => ({ + applyCanvas: vi.fn(), + applyPreset: vi.fn(), + notify: vi.fn(), hydrate: vi.fn(), navigate: vi.fn(), open: vi.fn(), + patchSettings: vi.fn(), + rename: vi.fn(), + requestLibraryWorkflowLoad: vi.fn(), + setSource: vi.fn(), switchTo: vi.fn(), })); @@ -16,24 +23,48 @@ vi.mock('@tanstack/react-router', async (importOriginal) => ({ useNavigate: () => harness.navigate, })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@features/workflow/react', () => ({ requestLibraryWorkflowLoad: harness.requestLibraryWorkflowLoad })); vi.mock('./WorkbenchContext', () => ({ useWorkbenchCommands: () => ({ - notifications: { add: vi.fn() }, - projects: { open: harness.open, switchTo: harness.switchTo }, + canvas: { apply: harness.applyCanvas }, + generation: { patchSettings: harness.patchSettings, setSource: harness.setSource }, + layout: { applyPreset: harness.applyPreset }, + notifications: { add: harness.notify }, + projects: { open: harness.open, rename: harness.rename, switchTo: harness.switchTo }, }), useWorkbenchHasHydrated: () => true, useWorkbenchPersistenceService: () => ({ hydrateProjectFromServer: harness.hydrate }), - useWorkbenchSelector: () => ['previous'], + useWorkbenchSelector: ( + select: (snapshot: { activeProject: { id: string }; projects: { id: string }[] }) => unknown + ) => select({ activeProject: { id: 'draft' }, projects: [{ id: 'previous' }, { id: 'draft' }] }), })); +import { accountLifecycle } from '@platform/state/accountLifecycle'; + +import { takeCanvasStartImage } from './projects/canvasStartImage'; +import { requestNewProject, takeNewProjectRequest } from './projects/newProjectRequest'; import { WorkbenchSessionController } from './WorkbenchSessionController'; +const START_IMAGE = { + boardId: 'none', + height: 750, + imageCategory: 'user' as const, + imageName: 'pasted.png', + imageUrl: '/images/pasted.png', + queuedAt: '', + sourceQueueItemId: '', + starred: false, + thumbnailUrl: '/thumbs/pasted.png', + width: 1000, +}; + let host: HTMLDivElement; let root: Root; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; beforeEach(() => { vi.resetAllMocks(); + accountLifecycle.activate('session-test', ':user:session-test'); host = document.createElement('div'); document.body.append(host); root = createRoot(host); @@ -42,6 +73,9 @@ beforeEach(() => { afterEach(async () => { await act(() => root.unmount()); host.remove(); + takeNewProjectRequest(); + takeCanvasStartImage('draft'); + accountLifecycle.invalidate(); }); const render = (search: WorkbenchSearch) => act(() => root.render()); @@ -80,3 +114,142 @@ it('does not consume a newer link when an older load finishes', async () => { expect(harness.open).not.toHaveBeenCalled(); expect(harness.navigate).not.toHaveBeenCalled(); }); + +it('applies the intent, then the staged dialog choices, to the new draft before stripping the link', async () => { + requestNewProject({ + generateSettings: { height: 840, width: 1256 }, + intent: 'canvas', + libraryWorkflow: null, + name: 'Portraits', + startImage: null, + }); + + await render({ intent: 'canvas', new: true }); + + expect(harness.applyPreset).toHaveBeenCalledWith('edit'); + expect(harness.setSource).toHaveBeenCalledWith('canvas'); + expect(harness.rename).toHaveBeenCalledWith('draft', 'Portraits'); + expect(harness.patchSettings).toHaveBeenCalledWith({ height: 840, width: 1256 }, 'draft', 'system'); + expect(harness.requestLibraryWorkflowLoad).not.toHaveBeenCalled(); + // A blank Canvas start still sizes the document to the chosen generate size. + expect(harness.applyCanvas.mock.calls).toEqual([ + ['draft', { height: 840, type: 'resizeCanvasDocument', width: 1256 }], + ['draft', { bbox: { height: 840, width: 1256, x: 0, y: 0 }, type: 'setCanvasBbox' }], + ]); + expect(harness.rename.mock.invocationCallOrder[0]).toBeGreaterThan(harness.setSource.mock.invocationCallOrder[0]!); + expect(harness.navigate).toHaveBeenCalledWith({ replace: true, search: {}, to: '/app' }); + expect(takeNewProjectRequest()).toBeNull(); +}); + +it('asks the workflow widget to load a picked library workflow over the blank one', async () => { + requestNewProject({ + generateSettings: null, + intent: 'workflow', + libraryWorkflow: { name: 'Upscale pipeline', workflowId: 'lib-1' }, + name: null, + startImage: null, + }); + + await render({ intent: 'workflow', new: true }); + + expect(harness.applyPreset).toHaveBeenCalledWith('automate'); + expect(harness.rename).not.toHaveBeenCalled(); + expect(harness.patchSettings).not.toHaveBeenCalled(); + expect(harness.requestLibraryWorkflowLoad).toHaveBeenCalledWith('lib-1', 'Upscale pipeline'); + expect(harness.navigate).toHaveBeenCalledWith({ replace: true, search: {}, to: '/app' }); +}); + +it('opens a plain intent draft when nothing was staged', async () => { + await render({ intent: 'generate', new: true }); + + expect(harness.applyPreset).toHaveBeenCalledWith('compose'); + expect(harness.rename).not.toHaveBeenCalled(); + expect(harness.patchSettings).not.toHaveBeenCalled(); + expect(harness.navigate).toHaveBeenCalledWith({ replace: true, search: {}, to: '/app' }); +}); + +it('leaves a staged request alone for links that do not create a project', async () => { + requestNewProject({ + generateSettings: null, + intent: 'workflow', + libraryWorkflow: null, + name: 'Kept', + startImage: null, + }); + + await render({ project: 'previous' }); + + expect(harness.rename).not.toHaveBeenCalled(); + expect(takeNewProjectRequest()?.name).toBe('Kept'); +}); + +it('drops a request staged for another start instead of landing it on this one', async () => { + requestNewProject({ + generateSettings: { height: 840, width: 1256 }, + intent: 'canvas', + libraryWorkflow: null, + name: 'Stale', + startImage: START_IMAGE, + }); + + await render({ intent: 'generate', new: true }); + + expect(harness.applyPreset).toHaveBeenCalledWith('compose'); + expect(harness.rename).not.toHaveBeenCalled(); + expect(harness.patchSettings).not.toHaveBeenCalled(); + expect(harness.applyCanvas).not.toHaveBeenCalled(); + expect(takeNewProjectRequest()).toBeNull(); + expect(takeCanvasStartImage('draft')).toBeNull(); +}); + +it('sizes a blank Canvas start to the chosen size', async () => { + requestNewProject({ + generateSettings: { height: 768, width: 1368 }, + intent: 'canvas', + libraryWorkflow: null, + name: null, + startImage: null, + }); + + await render({ intent: 'canvas', new: true }); + + expect(harness.applyCanvas.mock.calls).toEqual([ + ['draft', { height: 768, type: 'resizeCanvasDocument', width: 1368 }], + ['draft', { bbox: { height: 768, width: 1368, x: 0, y: 0 }, type: 'setCanvasBbox' }], + ]); + expect(takeCanvasStartImage('draft')).toBeNull(); +}); + +it('sizes the canvas to a start image and stages it for the Canvas widget', async () => { + requestNewProject({ + generateSettings: { height: 752, width: 1000 }, + intent: 'canvas', + libraryWorkflow: null, + name: null, + startImage: START_IMAGE, + }); + + await render({ intent: 'canvas', new: true }); + + expect(harness.applyCanvas.mock.calls).toEqual([ + ['draft', { height: 750, type: 'resizeCanvasDocument', width: 1000 }], + ['draft', { bbox: { height: 750, width: 1000, x: 0, y: 0 }, type: 'setCanvasBbox' }], + ]); + expect(takeCanvasStartImage('draft')).toBe(START_IMAGE); + expect(harness.navigate).toHaveBeenCalledWith({ replace: true, search: {}, to: '/app' }); +}); + +it('ignores a start image for starts other than Canvas', async () => { + requestNewProject({ + generateSettings: null, + intent: 'generate', + libraryWorkflow: null, + name: null, + startImage: START_IMAGE, + }); + + await render({ intent: 'generate', new: true }); + + expect(harness.applyCanvas).not.toHaveBeenCalled(); + expect(takeCanvasStartImage('draft')).toBeNull(); +}); diff --git a/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.tsx b/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.tsx index bfef3bad2fb..4c064cd72ec 100644 --- a/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.tsx +++ b/invokeai/frontend/webv2/src/workbench/WorkbenchSessionController.tsx @@ -1,3 +1,4 @@ +import { requestLibraryWorkflowLoad } from '@features/workflow/react'; import { useMountEffect } from '@platform/react/useMountEffect'; import { areArraysEqual } from '@platform/state/selectors'; import { useNavigate } from '@tanstack/react-router'; @@ -6,6 +7,8 @@ import { useTranslation } from 'react-i18next'; import type { WorkbenchSearch } from './projects/session'; +import { requestCanvasStartImage } from './projects/canvasStartImage'; +import { takeNewProjectRequest } from './projects/newProjectRequest'; import { describeRefusedProject } from './projects/projectLoadRefusal'; import { useWorkbenchCommands, @@ -19,6 +22,7 @@ const HydratedSessionController = ({ search }: { search: WorkbenchSearch }) => { const navigate = useNavigate(); const persistence = useWorkbenchPersistenceService(); const projectIds = useWorkbenchSelector((snapshot) => snapshot.projects.map((project) => project.id), areArraysEqual); + const activeProjectId = useWorkbenchSelector((snapshot) => snapshot.activeProject.id); const { t } = useTranslation(); useMountEffect(() => { @@ -31,6 +35,48 @@ const HydratedSessionController = ({ search }: { search: WorkbenchSearch }) => { commands.generation.setSource(intent.sourceId); } + // The dialog's choices ride alongside the URL intent; the fresh draft is the active project by now. A request + // staged for another start is stale (a reused link, say) and must not land here. + const staged = takeNewProjectRequest(); + const request = staged && staged.intent === intent?.id ? staged : null; + + if (request?.name) { + commands.projects.rename(activeProjectId, request.name); + } + + if (request?.generateSettings) { + commands.generation.patchSettings(request.generateSettings, activeProjectId, 'system'); + } + + // The Workflow widget's pending loader takes it over the draft's untouched blank workflow. + if (request?.libraryWorkflow) { + requestLibraryWorkflowLoad(request.libraryWorkflow.workflowId, request.libraryWorkflow.name); + } + + // A Canvas start sizes the canvas document to its chosen size (an image's exact dimensions, else the generate + // size), as creating a canvas from gallery images does. The Canvas widget lays an image down once it mounts: + // the import path is widget territory the boot graph does not carry. + if (intent?.id === 'canvas') { + const startImage = request?.startImage ?? null; + const settings = request?.generateSettings; + const canvasSize = startImage + ? { height: startImage.height, width: startImage.width } + : typeof settings?.width === 'number' && typeof settings.height === 'number' + ? { height: settings.height, width: settings.width } + : null; + + if (canvasSize) { + const { height, width } = canvasSize; + + commands.canvas.apply(activeProjectId, { height, type: 'resizeCanvasDocument', width }); + commands.canvas.apply(activeProjectId, { bbox: { height, width, x: 0, y: 0 }, type: 'setCanvasBbox' }); + } + + if (startImage) { + requestCanvasStartImage({ image: startImage, projectId: activeProjectId }); + } + } + void navigate({ replace: true, search: {}, to: '/app' }); return; } diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectButton.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectButton.tsx index d3b777300db..cd6dd7baa6f 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectButton.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectButton.tsx @@ -1,66 +1,60 @@ -import type { LaunchpadStartEntry } from '@workbench/launchpad/startEntries'; - -import { Icon, Menu, Portal } from '@chakra-ui/react'; -import { Button, IconButton } from '@platform/ui/Button'; -import { Group } from '@platform/ui/Group'; -import { MenuActionItem, MenuContent } from '@platform/ui/Menu'; -import { Link, useNavigate } from '@tanstack/react-router'; -import { LAUNCHPAD_START_ENTRIES } from '@workbench/launchpad/startEntries'; -import { ChevronDownIcon, PlusIcon } from 'lucide-react'; -import { useCallback } from 'react'; +import { Icon } from '@chakra-ui/react'; +import { Button } from '@platform/ui/Button'; +import { Dialog } from '@platform/ui/Dialog'; +import { toaster } from '@platform/ui/toaster'; +import { PlusIcon } from 'lucide-react'; +import { lazy, Suspense, useCallback, useState, type ComponentProps, type ComponentType } from 'react'; import { useTranslation } from 'react-i18next'; -/** Plain clicks use the last arrangement; the caret offers the same starts as the Home tiles. */ +import type { NewProjectDialog } from './NewProjectDialog'; + +/** + * Opens the New-project dialog; the Home tiles stay the one-click path. The dialog pulls the generation policies and + * model catalog, so its module loads on the first open rather than with the Launchpad. + */ -const NEW_PROJECT_SEARCH = { new: true } as const; -const MENU_POSITIONING = { placement: 'bottom-end' } as const; +const loadNewProjectDialog = () => import('./NewProjectDialog'); +// A chunk that fails to load (an offline tab, a stale deploy) renders nothing; the open handler closes and says so. +type NewProjectDialogComponent = ComponentType>; +const NothingToShow: NewProjectDialogComponent = () => null; +const LazyNewProjectDialog = lazy(() => + loadNewProjectDialog() + .then((module) => ({ default: module.NewProjectDialog as NewProjectDialogComponent })) + .catch(() => ({ default: NothingToShow })) +); +// Modal from the click onward: while the dialog's module is still on its way, shortcuts must already be off. +const PENDING_DIALOG = ; export const NewProjectButton = ({ variant = 'solid' }: { variant?: 'outline' | 'solid' }) => { const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + // Once mounted the dialog stays, so its close animation and the lazy chunk are paid for once. + const [hasOpened, setHasOpened] = useState(false); - return ( - - - - - - - - - - - - - {t('launchpad.home.intents.heading')} - {LAUNCHPAD_START_ENTRIES.map((entry) => ( - - ))} - - - - - - - ); -}; - -const NewProjectStartMenuItem = ({ entry }: { entry: LaunchpadStartEntry }) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const handleSelect = useCallback(() => void navigate({ search: entry.search, to: '/app' }), [entry.search, navigate]); + const open = useCallback(() => { + setHasOpened(true); + setIsOpen(true); + // Start the catalog loads alongside the chunk, so the form rarely shows its loading state. + void loadNewProjectDialog() + .then((module) => module.prepareNewProjectDialog()) + .catch(() => { + setIsOpen(false); + toaster.create({ title: t('projects.newProjectDialog.loadFailed'), type: 'error' }); + }); + }, [t]); + const close = useCallback(() => setIsOpen(false), []); return ( - + <> + + {hasOpened ? ( + + + + ) : null} + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.browser.test.tsx new file mode 100644 index 00000000000..8ba1f4da977 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.browser.test.tsx @@ -0,0 +1,544 @@ +import type { GalleryImage } from '@features/gallery'; +import type { ModelConfig } from '@features/models/core/types'; +import type { WorkflowLibraryBrowseSnapshot, WorkflowLibraryEntry } from '@features/workflow/data/libraryBrowseStore'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { setModelsSnapshotForTests } from '@features/models/data/modelsStore'; +import { auditAccessibility, contrastOffenderTexts } from '@platform/browser/auditAccessibility.testing'; +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { applyThemeToRoot } from '@theme/applyTheme'; +import { DEFAULT_THEME_ID, system } from '@theme/system'; +import { takeNewProjectRequest } from '@workbench/projects/newProjectRequest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; + +const pending = vi.hoisted(() => () => new Promise(() => {})); + +const harness = vi.hoisted(() => ({ + canManageModels: true, + /** What the clipboard holds for the next read. */ + clipboard: null as Blob | null, + importFile: vi.fn(), + navigate: vi.fn(), + patchPreferences: vi.fn(() => Promise.resolve()), + preferences: { + enableModelDescriptions: true, + launchpadNewProjectIntent: 'generate', + launchpadNewProjectModelKey: null as string | null, + }, + upload: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + values + ? `${key}:${Object.entries(values) + .map(([name, value]) => `${name}=${String(value)}`) + .join(',')}` + : key, + }), +})); +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal>()), + Link: ({ children, search: _search, to, ...props }: { children?: unknown; search?: unknown; to: string }) => ( + + {children as never} + + ), + useNavigate: () => harness.navigate, +})); +vi.mock('@features/identity', async (importOriginal) => ({ + ...(await importOriginal()), + useCapabilities: () => ({ canManageModels: harness.canManageModels }), +})); +vi.mock('@workbench/projects/useProjectFileActions', () => ({ + useImportProjectFile: () => harness.importFile, +})); +vi.mock('@workbench/settings/store', () => ({ + getWorkbenchPreferences: () => harness.preferences, + patchWorkbenchPreferences: harness.patchPreferences, + useWorkbenchPreferenceSelector: (select: (preferences: typeof harness.preferences) => unknown) => + select(harness.preferences), +})); +// The served capability table decides grids and optimal sizes; answer with the pinned fixture instead of the network. +vi.mock('@features/generation/data/architectureCapabilitiesApi', async () => { + const { default: rows } = await import('@features/generation/core/__fixtures__/architectureCapabilities.json'); + + return { getArchitectureCapabilities: () => Promise.resolve(rows) }; +}); +// The clipboard and the upload are the two seams of the image start; the slot's own picker is covered by the gallery. +vi.mock('@platform/browser/clipboardImage', () => ({ + readClipboardImage: () => Promise.resolve(harness.clipboard), +})); +vi.mock('@features/gallery/data/backend', async (importOriginal) => ({ + ...(await importOriginal>()), + uploadGalleryImage: harness.upload, +})); +// The model picker pins related models once it opens; there are none here. +vi.mock('@features/models/data/relationshipsApi', () => ({ + addModelRelationship: pending, + getRelatedModelKeys: () => Promise.resolve([]), + removeModelRelationship: pending, +})); + +// Drive a real external browse store so the picker's subscriptions and selectors run, while owning its contents. +const browse = vi.hoisted(() => ({ + ensureWorkflowLibraryBrowseLoaded: vi.fn(() => Promise.resolve()), + getSnapshot: null as null | (() => WorkflowLibraryBrowseSnapshot), + loadNextWorkflowLibraryPage: vi.fn(), + setSnapshot: null as null | ((snapshot: WorkflowLibraryBrowseSnapshot) => void), + setWorkflowLibraryBrowseFilter: vi.fn(), +})); + +const EMPTY_BROWSE: WorkflowLibraryBrowseSnapshot = { + entries: [], + error: null, + filter: { category: 'user', search: '', tag: null }, + page: 0, + pages: 0, + status: 'idle', + tagCounts: [], + total: 0, + userTotal: null, +}; + +vi.mock('@features/workflow/data/libraryBrowseStore', async () => { + const { createExternalStore } = await import('@platform/state/externalStore'); + const store = createExternalStore(EMPTY_BROWSE); + + browse.getSnapshot = store.getSnapshot; + browse.setSnapshot = store.setSnapshot; + + return { + ensureWorkflowLibraryBrowseLoaded: browse.ensureWorkflowLibraryBrowseLoaded, + getWorkflowLibraryBrowseSnapshot: store.getSnapshot, + loadNextWorkflowLibraryPage: browse.loadNextWorkflowLibraryPage, + setWorkflowLibraryBrowseFilter: browse.setWorkflowLibraryBrowseFilter, + useWorkflowLibraryBrowseSelector: store.useSelector, + }; +}); + +const { NewProjectButton } = await import('./NewProjectButton'); +// The button loads the dialog lazily; evaluate it now so the mocked stores above exist before the first open. +await import('./NewProjectDialog'); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const model = (overrides: Partial & Pick): ModelConfig => ({ + base: 'sdxl', + default_settings: null, + description: null, + file_size: 1024, + format: 'checkpoint', + hash: `hash-${overrides.key}`, + path: `/models/${overrides.key}.safetensors`, + source: `/models/${overrides.key}.safetensors`, + source_type: 'path', + trigger_phrases: [], + type: 'main', + ...overrides, +}); + +const CATALOG: ModelConfig[] = [ + model({ default_settings: { height: 1216, width: 832 }, key: 'sdxl-zeta', name: 'Zeta XL' }), + model({ format: 'lycoris', key: 'lora', name: 'Lora', type: 'lora' }), + model({ base: 'sd-1', key: 'sd1', name: 'Dreamshaper' }), + model({ base: 'flux', format: 'diffusers', key: 'flux-dev', name: 'FLUX Dev' }), + model({ key: 'sdxl-alpha', name: 'Alpha XL' }), +]; + +const START_IMAGE: GalleryImage = { + boardId: 'none', + height: 750, + imageCategory: 'user', + imageName: 'pasted.png', + imageUrl: '/images/pasted.png', + queuedAt: '', + sourceQueueItemId: '', + starred: false, + thumbnailUrl: '/thumbs/pasted.png', + width: 1000, +}; + +const libraryEntry = (workflowId: string, name: string): WorkflowLibraryEntry => ({ + enrichment: { status: 'pending' }, + item: { + category: 'user', + description: `${name} description`, + name, + revision: 1, + thumbnail_url: null, + workflow_id: workflowId, + }, + tags: [], +}); + +const LIBRARY: WorkflowLibraryBrowseSnapshot = { + ...EMPTY_BROWSE, + entries: [libraryEntry('lib-1', 'Upscale pipeline'), libraryEntry('lib-2', 'Portrait retouch')], + pages: 1, + status: 'loaded', + tagCounts: [{ count: 2, tag: 'photo' }], + total: 2, + userTotal: 2, +}; + +let host: HTMLDivElement; +let root: Root; +let queryClient: QueryClient; + +const render = () => + act(() => + root.render( + + + + + + ) + ); + +const dialog = () => page.getByRole('dialog'); +const start = (id: string) => page.getByRole('option', { name: `launchpad.home.intents.${id}` }); +const paneHeading = () => dialog().getByRole('heading', { level: 3 }); +const modelTrigger = () => dialog().getByRole('button', { name: /^projects\.newProjectDialog\.model/ }); +const widthInput = () => dialog().getByRole('textbox', { name: 'projects.newProjectDialog.width' }); +const heightInput = () => dialog().getByRole('textbox', { name: 'projects.newProjectDialog.height' }); +const chip = (ratio: string) => dialog().getByRole('button', { name: ratio, exact: true }); +const createButton = () => dialog().getByRole('button', { name: 'projects.newProjectDialog.create' }); +const blankWorkflow = () => dialog().getByRole('button', { name: /projects\.newProjectDialog\.blankWorkflow/ }); +const workflowCard = (workflowId: string) => + document.querySelector(`[data-workflow-card="${workflowId}"]`)!; +const startFromRadio = (id: 'startBlank' | 'startImage') => + dialog().getByRole('radio', { name: `projects.newProjectDialog.${id}` }); +// The segment's hidden input sits under its label; the label is what a pointer reaches. +const pickStartFrom = (id: 'startBlank' | 'startImage') => + userEvent.click(dialog().getByText(`projects.newProjectDialog.${id}`)); +const clipboardButton = () => dialog().getByRole('button', { name: 'projects.newProjectDialog.fromClipboard' }); +const sizeText = (width: number, height: number) => + dialog().getByText(`projects.newProjectDialog.sizeValue:height=${height},width=${width}`); + +const open = async () => { + await render(); + await userEvent.click(page.getByRole('button', { name: 'projects.newProject' })); + await expect.element(dialog()).toBeVisible(); +}; + +beforeEach(async () => { + await page.viewport(1280, 900); + vi.clearAllMocks(); + harness.canManageModels = true; + harness.clipboard = null; + harness.preferences = { + enableModelDescriptions: true, + launchpadNewProjectIntent: 'generate', + launchpadNewProjectModelKey: null, + }; + applyThemeToRoot(DEFAULT_THEME_ID); + accountLifecycle.activate('new-project-test', ':user:new-project-test'); + setModelsSnapshotForTests({ error: null, models: CATALOG, status: 'loaded' }); + browse.setSnapshot!(LIBRARY); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + queryClient.clear(); + host.remove(); + takeNewProjectRequest(); + accountLifecycle.invalidate(); +}); + +describe('NewProjectDialog', () => { + it('opens on the remembered start with focus on it, and arrow keys move the selection and the pane', async () => { + harness.preferences.launchpadNewProjectIntent = 'canvas'; + await open(); + + await expect.element(start('canvas')).toHaveFocus(); + await expect.element(start('canvas')).toHaveAttribute('aria-selected', 'true'); + await expect.element(paneHeading()).toHaveTextContent('launchpad.home.intents.canvas'); + + await userEvent.keyboard('{ArrowDown}'); + await expect.element(start('upscale')).toHaveFocus(); + await expect.element(start('upscale')).toHaveAttribute('aria-selected', 'true'); + await expect.element(start('canvas')).toHaveAttribute('aria-selected', 'false'); + await expect.element(paneHeading()).toHaveTextContent('launchpad.home.intents.upscale'); + await expect.element(dialog().getByText('projects.newProjectDialog.upscaleHint')).toBeVisible(); + + await userEvent.keyboard('{Home}'); + await expect.element(start('generate')).toHaveFocus(); + await expect.element(paneHeading()).toHaveTextContent('launchpad.home.intents.generate'); + + expect(await auditAccessibility(dialog().element())).toEqual([]); + + await userEvent.keyboard('{Escape}'); + await expect.element(dialog()).not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'projects.newProject' })).toHaveFocus(); + }); + + it('offers only generate-capable models in the shared picker, and the pick sets the default size', async () => { + harness.preferences.launchpadNewProjectModelKey = 'sdxl-zeta'; + await open(); + + await vi.waitFor(() => expect(modelTrigger().element().textContent).toContain('Zeta XL')); + await expect.element(sizeText(832, 1216)).toBeVisible(); + + await userEvent.click(modelTrigger()); + const listbox = page.getByRole('listbox', { name: /models\.scopeAvailable/ }); + await expect.element(listbox).toBeVisible(); + for (const name of ['Alpha XL', 'Dreamshaper', 'FLUX Dev', 'Zeta XL']) { + await expect.element(listbox.getByRole('option', { name: new RegExp(name) })).toBeVisible(); + } + expect(listbox.getByRole('option', { name: /Lora/ }).elements()).toEqual([]); + + await userEvent.click(listbox.getByRole('option', { name: /Dreamshaper/ })); + await vi.waitFor(() => expect(modelTrigger().element().textContent).toContain('Dreamshaper')); + await expect.element(widthInput()).toHaveValue('512'); + await expect.element(heightInput()).toHaveValue('512'); + await expect.element(chip('1:1')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('reshapes the size from a chip, snaps typed values to the grid, and swaps', async () => { + harness.preferences.launchpadNewProjectModelKey = 'sdxl-alpha'; + await open(); + + await userEvent.click(chip('3:2')); + await expect.element(widthInput()).toHaveValue('1256'); + await expect.element(heightInput()).toHaveValue('840'); + await expect.element(sizeText(1256, 840)).toBeVisible(); + await expect.element(chip('3:2')).toHaveAttribute('aria-pressed', 'true'); + + await userEvent.fill(widthInput(), '1003'); + await userEvent.tab(); + await expect.element(widthInput()).toHaveValue('1000'); + await expect.element(sizeText(1000, 840)).toBeVisible(); + await expect.element(chip('3:2')).toHaveAttribute('aria-pressed', 'false'); + + await userEvent.click(dialog().getByRole('button', { name: 'projects.newProjectDialog.swapDimensions' })); + await expect.element(widthInput()).toHaveValue('840'); + await expect.element(heightInput()).toHaveValue('1000'); + }); + + it('keeps a chip through tabbing across the inputs and falls back from a remembered model that is gone', async () => { + harness.preferences.launchpadNewProjectModelKey = 'uninstalled'; + await open(); + + // The first selectable model by name stands in for the remembered one. + await vi.waitFor(() => expect(modelTrigger().element().textContent).toContain('Alpha XL')); + + await userEvent.click(chip('3:2')); + await userEvent.click(widthInput()); + await userEvent.tab(); + await userEvent.tab(); + await expect.element(chip('3:2')).toHaveAttribute('aria-pressed', 'true'); + await expect.element(widthInput()).toHaveValue('1256'); + + await userEvent.click(createButton()); + + expect(takeNewProjectRequest()?.generateSettings).toEqual( + expect.objectContaining({ + aspectRatioId: '3:2', + aspectRatioValue: 1.5, + height: 840, + modelKey: 'sdxl-alpha', + width: 1256, + }) + ); + }); + + it('creates with the staged settings, remembers the start and model, and opens the editor', async () => { + harness.preferences.launchpadNewProjectModelKey = 'sdxl-alpha'; + await open(); + + await userEvent.click(start('canvas')); + await userEvent.click(chip('16:9')); + await userEvent.fill(dialog().getByRole('textbox', { name: 'common.name' }), ' Portraits '); + await userEvent.click(createButton()); + + expect(takeNewProjectRequest()).toEqual({ + generateSettings: expect.objectContaining({ + aspectRatioId: '16:9', + aspectRatioIsLocked: false, + height: 768, + modelKey: 'sdxl-alpha', + width: 1368, + }), + intent: 'canvas', + libraryWorkflow: null, + name: 'Portraits', + startImage: null, + }); + expect(harness.patchPreferences).toHaveBeenCalledWith({ + launchpadNewProjectIntent: 'canvas', + launchpadNewProjectModelKey: 'sdxl-alpha', + }); + expect(harness.navigate).toHaveBeenCalledWith({ search: { intent: 'canvas', new: true }, to: '/app' }); + await expect.element(dialog()).not.toBeInTheDocument(); + }); + + it('creates from Enter in the name field, keeping the default name when blank', async () => { + await open(); + + await userEvent.click(start('upscale')); + await userEvent.click(dialog().getByRole('textbox', { name: 'common.name' })); + await userEvent.keyboard('{Enter}'); + + expect(takeNewProjectRequest()).toEqual({ + generateSettings: null, + intent: 'upscale', + libraryWorkflow: null, + name: null, + startImage: null, + }); + expect(harness.patchPreferences).toHaveBeenCalledWith({ launchpadNewProjectIntent: 'upscale' }); + expect(harness.navigate).toHaveBeenCalledWith({ search: { intent: 'upscale', new: true }, to: '/app' }); + }); + + it('shows the workflow library for the Workflow start with Blank selected, and stages a picked workflow', async () => { + await open(); + + await userEvent.click(start('workflow')); + await expect.element(blankWorkflow()).toHaveAttribute('aria-pressed', 'true'); + expect(browse.ensureWorkflowLibraryBrowseLoaded).toHaveBeenCalledTimes(1); + await expect + .element(dialog().getByRole('tab', { name: 'workflowLibrary.yours' })) + .toHaveAttribute('aria-selected', 'true'); + await vi.waitFor(() => expect(workflowCard('lib-1')).not.toBeNull()); + expect(document.querySelector('[data-workflow-card-menu]')).toBeNull(); + // The library card's "not run yet" placeholder is a known low-contrast label; nothing else may fail. + const violations = await auditAccessibility(dialog().element()); + expect(violations.map((violation) => violation.id)).toEqual(['color-contrast']); + expect(contrastOffenderTexts(violations)).toEqual(['workflowLibrary.notRunYet', 'workflowLibrary.notRunYet']); + + await userEvent.click(workflowCard('lib-1')); + await expect.element(blankWorkflow()).toHaveAttribute('aria-pressed', 'false'); + expect(workflowCard('lib-1').getAttribute('aria-pressed')).toBe('true'); + + await userEvent.click(blankWorkflow()); + await expect.element(blankWorkflow()).toHaveAttribute('aria-pressed', 'true'); + expect(workflowCard('lib-1').getAttribute('aria-pressed')).toBe('false'); + + await userEvent.click(workflowCard('lib-2')); + await userEvent.click(createButton()); + + expect(takeNewProjectRequest()).toEqual({ + generateSettings: null, + intent: 'workflow', + libraryWorkflow: { name: 'Portrait retouch', workflowId: 'lib-2' }, + name: null, + startImage: null, + }); + expect(harness.navigate).toHaveBeenCalledWith({ search: { intent: 'workflow', new: true }, to: '/app' }); + }); + + it('creates straight from Enter on a focused workflow card', async () => { + await open(); + + await userEvent.click(start('workflow')); + await vi.waitFor(() => expect(workflowCard('lib-1')).not.toBeNull()); + await userEvent.click(workflowCard('lib-1')); + await userEvent.keyboard('{Enter}'); + + expect(takeNewProjectRequest()?.libraryWorkflow).toEqual({ name: 'Upscale pipeline', workflowId: 'lib-1' }); + await expect.element(dialog()).not.toBeInTheDocument(); + }); + + it('starts a Canvas project from a pasted image, sizing the canvas to it', async () => { + harness.preferences.launchpadNewProjectModelKey = 'sdxl-alpha'; + harness.upload.mockResolvedValue(START_IMAGE); + await open(); + + await userEvent.click(start('canvas')); + await expect.element(startFromRadio('startBlank')).toBeChecked(); + expect(clipboardButton().elements()).toEqual([]); + + await pickStartFrom('startImage'); + await expect.element(dialog().getByRole('button', { name: 'widgets.gallery.picker.chooseImage' })).toBeVisible(); + // Image chosen as the start but none picked yet: Create waits and says why. + await expect.element(createButton()).toBeDisabled(); + await expect.element(dialog().getByText('projects.newProjectDialog.chooseImageOrBlank')).toBeVisible(); + + // Nothing on the clipboard is an ordinary outcome, said inline rather than toasted. + await userEvent.click(clipboardButton()); + await expect.element(dialog().getByText('projects.newProjectDialog.clipboardEmpty')).toBeVisible(); + expect(harness.upload).not.toHaveBeenCalled(); + + harness.clipboard = new Blob(['png'], { type: 'image/png' }); + await userEvent.click(clipboardButton()); + await vi.waitFor(() => expect(harness.upload).toHaveBeenCalledTimes(1)); + const [file, boardId] = harness.upload.mock.calls[0] as [File, string]; + expect(file.name).toBe('clipboard.png'); + expect(file.type).toBe('image/png'); + expect(boardId).toBe('none'); + + // The image sizes the canvas: 1000×750 on SDXL's 8px grid is 1000×752, and the size controls rest. + await expect.element(widthInput()).toHaveValue('1000'); + await expect.element(heightInput()).toHaveValue('752'); + await expect.element(widthInput()).toBeDisabled(); + await expect.element(chip('3:2')).toBeDisabled(); + await expect.element(sizeText(1000, 752)).toBeVisible(); + await expect.element(dialog().getByText('projects.newProjectDialog.imageSizeHint')).toBeVisible(); + await expect.element(dialog().getByText('projects.newProjectDialog.clipboardEmpty')).not.toBeInTheDocument(); + await expect.element(createButton()).toBeEnabled(); + expect(await auditAccessibility(dialog().element())).toEqual([]); + + // Blank forgets the image and frees the size again. + await pickStartFrom('startBlank'); + await expect.element(widthInput()).toBeEnabled(); + await expect.element(widthInput()).toHaveValue('1024'); + await pickStartFrom('startImage'); + await expect.element(widthInput()).toHaveValue('1024'); + + await userEvent.click(clipboardButton()); + await vi.waitFor(() => expect(harness.upload).toHaveBeenCalledTimes(2)); + await expect.element(widthInput()).toHaveValue('1000'); + await userEvent.click(createButton()); + + expect(takeNewProjectRequest()).toEqual({ + generateSettings: expect.objectContaining({ aspectRatioId: '4:3', height: 752, width: 1000 }), + intent: 'canvas', + libraryWorkflow: null, + name: null, + startImage: expect.objectContaining({ height: 750, imageName: 'pasted.png', width: 1000 }), + }); + }); + + it('runs the import flow from the rail and closes', async () => { + await open(); + + await userEvent.click(dialog().getByRole('button', { name: 'projects.newProjectDialog.importFile' })); + + expect(harness.importFile).toHaveBeenCalledTimes(1); + await expect.element(dialog()).not.toBeInTheDocument(); + }); + + it('blocks Create without a compatible model and points admins at the model manager', async () => { + setModelsSnapshotForTests({ models: CATALOG.filter((entry) => entry.type === 'lora'), status: 'loaded' }); + await open(); + + await expect.element(dialog().getByText('projects.newProjectDialog.noModelsTitle').first()).toBeVisible(); + const managerLink = dialog().getByRole('link', { name: 'projects.newProjectDialog.openModelManager' }); + await expect.element(managerLink).toHaveAttribute('href', '/models'); + await expect.element(createButton()).toBeDisabled(); + expect(await auditAccessibility(dialog().element())).toEqual([]); + + await userEvent.click(start('workflow')); + await expect.element(createButton()).toBeEnabled(); + }); + + it('hides the model manager link from users who cannot manage models', async () => { + harness.canManageModels = false; + setModelsSnapshotForTests({ models: [], status: 'loaded' }); + await open(); + + await expect.element(dialog().getByText('projects.newProjectDialog.noModelsTitle').first()).toBeVisible(); + expect(dialog().getByRole('link', { name: 'projects.newProjectDialog.openModelManager' }).elements()).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.tsx new file mode 100644 index 00000000000..f2ceb2d4833 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/NewProjectDialog.tsx @@ -0,0 +1,933 @@ +import type { GalleryBoard, GalleryImage, GalleryItem } from '@features/gallery'; +/* eslint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-jsx-as-prop */ +import type { WorkflowLibraryListItem } from '@features/workflow/queries'; +import type { LaunchpadIntentId } from '@workbench/launchpad/intents'; +import type { LaunchpadStartEntry } from '@workbench/launchpad/startEntries'; +import type { ProjectRecordDTO } from '@workbench/projects/api'; +import type { FormEvent, KeyboardEvent, RefObject } from 'react'; + +import { Box, chakra, HStack, Icon, Input, Portal, Stack, Text } from '@chakra-ui/react'; +import { GalleryMediaSlot } from '@features/gallery/mediaSlot'; +import { + galleryImageItemToGalleryImage, + GalleryHostProvider, + isGalleryImageItem, + useGalleryUploadAction, + type GalleryHost, + type GalleryPickerAccept, +} from '@features/gallery/picker'; +import { + ensureArchitectureCapabilitiesLoaded, + getArchitectureCapabilitiesSnapshot, + subscribeArchitectureCapabilities, + type ArchitectureCapabilitiesSnapshot, +} from '@features/generation/runtime'; +import { isGenerateModelSelectable } from '@features/generation/settings'; +import { useCapabilities } from '@features/identity'; +import { + ensureModelsLoaded, + ModelsUiProvider, + useModelsSelector, + type ModelConfig, + type ModelsUiAdapter, + type ModelTaxonomyType, +} from '@features/models'; +import { ModelSelect } from '@features/models/react'; +import { WorkflowLibraryPicker } from '@features/workflow/launchpad'; +import { readClipboardImage } from '@platform/browser/clipboardImage'; +import { useScopedAction } from '@platform/react/useScopedAction'; +import { assertAccountScopeCurrent } from '@platform/state/accountLifecycle'; +import { useExternalStoreSelector } from '@platform/state/selectors'; +import { Button, CloseButton, IconButton } from '@platform/ui/Button'; +import { Dialog } from '@platform/ui/Dialog'; +import { EmptyState } from '@platform/ui/EmptyState'; +import { Field, FieldLabel } from '@platform/ui/Field'; +import { SegmentedControl } from '@platform/ui/SegmentedControl'; +import { toaster } from '@platform/ui/toaster'; +import { Tooltip } from '@platform/ui/Tooltip'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { LAUNCHPAD_START_ENTRIES } from '@workbench/launchpad/startEntries'; +import { requestNewProject } from '@workbench/projects/newProjectRequest'; +import { useImportProjectFile } from '@workbench/projects/useProjectFileActions'; +import { + getWorkbenchPreferences, + patchWorkbenchPreferences, + useWorkbenchPreferenceSelector, +} from '@workbench/settings/store'; +import { ArrowLeftRightIcon, BoxesIcon, ClipboardPasteIcon, FileUpIcon, SquareDashedIcon } from 'lucide-react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + buildNewProjectRequest, + commitDimension, + createNewProjectDraft, + getActiveAspectRatio, + getStartImage, + hasGenerationSettings, + listSelectableGenerateModels, + NEW_PROJECT_ASPECT_RATIOS, + resolveNewProjectModel, + resolveNewProjectSize, + selectAspectRatio, + selectModel, + setCanvasStart, + setStartImage, + swapDimensions, + type Dimensions, + type NewProjectAspectRatioId, + type NewProjectDraft, + type NewProjectModel, +} from './newProjectDraft'; + +/** + * One fixed frame for every start: the rail picks a start, the pane holds that start's choices, the footer names and + * creates. Generate and Canvas starts choose a model and size here; Workflow picks a library workflow or a blank one; + * the other starts explain what the editor asks for. + */ + +/** Called as the dialog opens: both catalogs are shared, so a second visit finds them loaded. */ +export const prepareNewProjectDialog = (): void => { + void ensureModelsLoaded(); + void ensureArchitectureCapabilitiesLoaded(); +}; + +const DIALOG_WIDTH = 'min(880px, calc(100vw - 2rem))'; +const DIALOG_HEIGHT = '560px'; +const DIALOG_MAX_HEIGHT = 'calc(100dvh - 4rem)'; +const RAIL_WIDTH = '220px'; +const PREVIEW_STAGE_PX = 96; +const PREVIEW_PAD_PX = 8; +const MODELS_ROUTE_SEARCH = {} as const; +const GENERATE_MODEL_TYPES: readonly ModelTaxonomyType[] = ['main', 'external_image_generator']; +const IMAGE_ONLY: GalleryPickerAccept = ['image']; +/** The Launchpad has no project board to seed the picker from and no Gallery widget to reveal. */ +const NO_GALLERY_VALUES: Record = {}; +const NO_BOARDS: GalleryBoard[] = []; +/** Pasted images land unfiled, where the slot's own uploads go without a project board. */ +const UPLOAD_BOARD_ID = 'none'; +const GALLERY_HOST: GalleryHost = { + galleryValues: NO_GALLERY_VALUES, + notifications: { + add: ({ kind, message, title }) => toaster.create({ description: message, title, type: kind }), + reportError: ({ message }) => toaster.create({ title: message, type: 'error' }), + }, + projectName: '', +}; + +const selectCapabilitiesView = (snapshot: ArchitectureCapabilitiesSnapshot) => ({ + error: snapshot.error, + status: snapshot.status, +}); + +/** The pasted file keeps the clipboard's encoding; the gallery names it by the extension. */ +const getClipboardFileName = (type: string): string => + type === 'image/webp' ? 'clipboard.webp' : type === 'image/jpeg' ? 'clipboard.jpg' : 'clipboard.png'; + +const START_IDS = LAUNCHPAD_START_ENTRIES.map((entry) => entry.id); + +const getAdjacentStart = (current: LaunchpadIntentId, key: string): LaunchpadIntentId | null => { + if (key === 'Home') { + return START_IDS[0] ?? null; + } + + if (key === 'End') { + return START_IDS.at(-1) ?? null; + } + + const direction = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : 0; + + if (direction === 0) { + return null; + } + + const index = START_IDS.indexOf(current); + + return START_IDS[(Math.max(index, 0) + direction + START_IDS.length) % START_IDS.length] ?? null; +}; + +const SectionHeading = ({ children, id }: { children: string; id?: string }) => ( + + {children} + +); + +const StartOption = ({ + entry, + isSelected, + ref, + onSelect, +}: { + entry: LaunchpadStartEntry; + isSelected: boolean; + ref: RefObject | undefined; + onSelect: (intent: LaunchpadIntentId) => void; +}) => { + const { t } = useTranslation(); + const handleClick = useCallback(() => onSelect(entry.id), [entry.id, onSelect]); + + return ( + + + + {t(entry.labelKey)} + + + ); +}; + +/** Typed values commit on blur or Enter; Enter stays here so a snapped value is seen before the project is created. */ +const DimensionInput = ({ + disabled, + value, + onCommit, +}: { + disabled: boolean; + value: number; + onCommit: (value: number) => void; +}) => { + const [text, setText] = useState(null); + const commit = (raw: string) => { + setText(null); + onCommit(Number.parseInt(raw, 10)); + }; + + return ( + { + if (text !== null) { + commit(event.currentTarget.value); + } + }} + onChange={(event) => setText(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commit(event.currentTarget.value); + } + }} + /> + ); +}; + +const SizePreview = ({ size }: { size: Dimensions }) => { + const { t } = useTranslation(); + const scale = (PREVIEW_STAGE_PX - PREVIEW_PAD_PX * 2) / Math.max(size.width, size.height, 1); + + return ( + + + ); +}; + +const GenerationFields = ({ + canvasStart, + model, + size, + activeRatio, + startImage, + onBusyChange, + onCanvasStartChange, + onModelChange, + onRatioSelect, + onDimensionCommit, + onStartImageChange, + onSwap, +}: { + /** Null for a Generate start, which has no Start-from control. */ + canvasStart: 'blank' | 'image' | null; + model: NewProjectModel | null; + size: Dimensions; + activeRatio: NewProjectAspectRatioId | null; + startImage: GalleryImage | null; + onBusyChange: (isBusy: boolean) => void; + onCanvasStartChange: (start: 'blank' | 'image') => void; + onModelChange: (model: NewProjectModel) => void; + onRatioSelect: (ratioId: NewProjectAspectRatioId) => void; + onDimensionCommit: (key: keyof Dimensions, value: number) => void; + onStartImageChange: (image: GalleryImage | null) => void; + onSwap: () => void; +}) => { + const { t } = useTranslation(); + const handleModelChange = useCallback( + (next: ModelConfig | null) => { + if (next && isGenerateModelSelectable(next)) { + onModelChange(next); + } + }, + [onModelChange] + ); + const swapLabel = t('projects.newProjectDialog.swapDimensions'); + // A chosen image sizes the canvas, so the size controls show its dimensions and rest. + const isSizeLocked = startImage !== null; + + return ( + <> + + + + + {/* The image choice drives the size, so it comes first on the Canvas start. */} + {canvasStart !== null ? ( + + ) : null} + + + {t('projects.newProjectDialog.size')} + + + + {NEW_PROJECT_ASPECT_RATIOS.map((ratioId) => { + const isActive = ratioId === activeRatio; + + return ( + + ); + })} + + + + onDimensionCommit('width', value)} + /> + + + + + + + + onDimensionCommit('height', value)} + /> + + + + + + + + ); +}; + +/** + * Blank, or an image the canvas opens on. The slot uploads files and picks from the gallery itself; the clipboard + * button pushes a pasted image through the picker's own upload action, so Create always holds a server image. + */ +const StartFromSection = ({ + canvasStart, + startImage, + onBusyChange, + onCanvasStartChange, + onStartImageChange, +}: { + canvasStart: 'blank' | 'image'; + startImage: GalleryImage | null; + /** The clipboard upload is in flight; Create waits for it. */ + onBusyChange: (isBusy: boolean) => void; + onCanvasStartChange: (start: 'blank' | 'image') => void; + onStartImageChange: (image: GalleryImage | null) => void; +}) => { + const { t } = useTranslation(); + const startOptions = useMemo( + () => [ + { label: t('projects.newProjectDialog.startBlank'), value: 'blank' }, + { label: t('projects.newProjectDialog.startImage'), value: 'image' }, + ], + [t] + ); + const handleStartChange = useCallback( + (value: string) => onCanvasStartChange(value === 'image' ? 'image' : 'blank'), + [onCanvasStartChange] + ); + + return ( + + {t('projects.newProjectDialog.startFrom')} + + + + {canvasStart === 'image' ? ( + + + + ) : null} + + ); +}; + +/** Inside the gallery host: the upload action reports through it, as the slot's picker does. */ +const StartImagePicker = ({ + startImage, + onBusyChange, + onStartImageChange, +}: { + startImage: GalleryImage | null; + onBusyChange: (isBusy: boolean) => void; + onStartImageChange: (image: GalleryImage | null) => void; +}) => { + const { t } = useTranslation(); + const uploadFiles = useGalleryUploadAction({ boards: NO_BOARDS, selectedBoardId: UPLOAD_BOARD_ID }); + const { isBusy, run } = useScopedAction(); + // The last clipboard attempt found nothing; cleared by the next attempt or a chosen image. + const [isClipboardEmpty, setIsClipboardEmpty] = useState(false); + const slotValue = useMemo( + () => + startImage + ? { height: startImage.height, kind: 'image' as const, name: startImage.imageName, width: startImage.width } + : null, + [startImage] + ); + const handleSlotChange = useCallback( + (item: GalleryItem | null) => { + setIsClipboardEmpty(false); + onStartImageChange(item && isGalleryImageItem(item) ? galleryImageItemToGalleryImage(item) : null); + }, + [onStartImageChange] + ); + const handleClipboard = () => { + setIsClipboardEmpty(false); + onBusyChange(true); + void run(async (owner) => { + const blob = await readClipboardImage(); + + assertAccountScopeCurrent(owner); + + if (!blob) { + setIsClipboardEmpty(true); + return; + } + + // Failures are reported by the action itself; an empty result is one it has already explained. + const [item] = await uploadFiles([new File([blob], getClipboardFileName(blob.type), { type: blob.type })]); + + assertAccountScopeCurrent(owner); + + if (item && isGalleryImageItem(item)) { + onStartImageChange(galleryImageItemToGalleryImage(item)); + } + }).finally(() => onBusyChange(false)); + }; + + return ( + + + + + + + {isClipboardEmpty ? ( + + {t('projects.newProjectDialog.clipboardEmpty')} + + ) : startImage ? ( + + {t('projects.newProjectDialog.imageSizeHint')} + + ) : null} + + + ); +}; + +/** The first row of the Workflow pane: a Create with nothing picked still opens one blank workflow. */ +const BlankWorkflowOption = ({ isSelected, onSelect }: { isSelected: boolean; onSelect: () => void }) => { + const { t } = useTranslation(); + + return ( + + + + {t('projects.newProjectDialog.blankWorkflow')} + + {t('projects.newProjectDialog.blankWorkflowDescription')} + + + + ); +}; + +const EditorHint = ({ intent }: { intent: LaunchpadIntentId }) => { + const { t } = useTranslation(); + + return ( + + {intent === 'upscale' ? t('projects.newProjectDialog.upscaleHint') : t('projects.newProjectDialog.videoHint')} + + ); +}; + +const NewProjectContent = ({ + selectedStartRef, + onClose, +}: { + selectedStartRef: RefObject; + onClose: () => void; +}) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { canManageModels } = useCapabilities(); + // Read once per open: the dialog remembers the last start and model, then follows the user's picks. + const [rememberedModelKey] = useState(() => getWorkbenchPreferences().launchpadNewProjectModelKey); + const [draft, setDraft] = useState(() => createNewProjectDraft(getWorkbenchPreferences().launchpadNewProjectIntent)); + const [isStartImageBusy, setIsStartImageBusy] = useState(false); + const startHeadingId = useId(); + const models = useModelsSelector((snapshot) => snapshot.models); + const catalogStatus = useModelsSelector((snapshot) => snapshot.status); + // Grids, optimal sizes and the model defaults come from the served capability table; the size re-resolves when + // it lands, and Create waits for it so fallback defaults never persist as a system patch. + const capabilities = useExternalStoreSelector( + subscribeArchitectureCapabilities, + getArchitectureCapabilitiesSnapshot, + selectCapabilitiesView + ); + + const selectableModels = useMemo(() => listSelectableGenerateModels(models), [models]); + const model = resolveNewProjectModel(draft, selectableModels, rememberedModelKey); + const size = resolveNewProjectSize(draft, model); + const entry = + LAUNCHPAD_START_ENTRIES.find((candidate) => candidate.id === draft.intent) ?? LAUNCHPAD_START_ENTRIES[0]!; + const needsModel = hasGenerationSettings(draft.intent); + const isCatalogPending = catalogStatus === 'idle' || catalogStatus === 'loading'; + const hasNoModels = catalogStatus === 'loaded' && selectableModels.length === 0; + // ModelSelect reports loading and load errors in its own list; the footer says why Create waits. + const isCapabilitiesPending = capabilities.status === 'idle' || capabilities.status === 'loading'; + const isCanvasImageStart = draft.intent === 'canvas' && draft.canvasStart === 'image'; + const createBlockedReason = !needsModel + ? null + : model === null + ? isCatalogPending + ? t('projects.newProjectDialog.modelLoading') + : catalogStatus === 'error' + ? t('projects.newProjectDialog.modelLoadFailed') + : t('projects.newProjectDialog.noModelsTitle') + : isCapabilitiesPending + ? t('common.loading') + : capabilities.status === 'error' + ? t('projects.newProjectDialog.capabilitiesLoadFailed') + : isStartImageBusy + ? t('common.loading') + : isCanvasImageStart && draft.startImage === null + ? t('projects.newProjectDialog.chooseImageOrBlank') + : null; + const canRetryCapabilities = needsModel && model !== null && capabilities.status === 'error'; + const handleRetryCapabilities = useCallback(() => void ensureArchitectureCapabilitiesLoaded(), []); + + const openImportedProject = useCallback( + async (record: ProjectRecordDTO) => { + await navigate({ search: { project: record.project_id }, to: '/app' }); + }, + [navigate] + ); + const importFile = useImportProjectFile(openImportedProject); + const handleImport = useCallback(() => { + onClose(); + importFile(); + }, [importFile, onClose]); + + // Creation reads the draft it is given, so a pick that creates in the same gesture is never a render behind. + const create = (candidate: NewProjectDraft) => { + if (createBlockedReason !== null) { + return; + } + + const request = buildNewProjectRequest({ draft: candidate, model, models }); + + // The model preference belongs to the Generate and Canvas starts; other starts leave it as it was. + void patchWorkbenchPreferences({ + launchpadNewProjectIntent: candidate.intent, + ...(needsModel ? { launchpadNewProjectModelKey: model?.key ?? null } : {}), + }); + requestNewProject(request); + void navigate({ search: { intent: candidate.intent, new: true }, to: '/app' }); + onClose(); + }; + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + create(draft); + }; + + const handleStartSelect = useCallback( + (intent: LaunchpadIntentId) => setDraft((current) => ({ ...current, intent })), + [] + ); + const handleRailKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + create(draft); + + return; + } + + const next = getAdjacentStart(draft.intent, event.key); + + if (!next) { + return; + } + + event.preventDefault(); + setDraft((current) => ({ ...current, intent: next })); + event.currentTarget.querySelector(`[data-intent="${next}"]`)?.focus(); + }; + const handleModelChange = useCallback( + (next: NewProjectModel) => setDraft((current) => selectModel(current, next)), + [] + ); + // The size handlers close over the resolved model; the React Compiler memoizes them, manual deps cannot. + const handleRatioSelect = (ratioId: NewProjectAspectRatioId) => + setDraft((current) => selectAspectRatio(current, ratioId, model)); + const handleDimensionCommit = (key: keyof Dimensions, value: number) => + setDraft((current) => commitDimension(current, model, key, value)); + const handleSwap = () => setDraft((current) => swapDimensions(current, model)); + const handleCanvasStartChange = useCallback( + (start: 'blank' | 'image') => setDraft((current) => setCanvasStart(current, start)), + [] + ); + const handleStartImageChange = useCallback( + (image: GalleryImage | null) => setDraft((current) => setStartImage(current, image)), + [] + ); + const handleBlankWorkflowSelect = useCallback( + () => setDraft((current) => ({ ...current, libraryWorkflow: null })), + [] + ); + const handleWorkflowSelect = useCallback( + (item: WorkflowLibraryListItem) => + setDraft((current) => ({ ...current, libraryWorkflow: { name: item.name, workflowId: item.workflow_id } })), + [] + ); + const handleWorkflowOpen = (item: WorkflowLibraryListItem) => + create({ ...draft, libraryWorkflow: { name: item.name, workflowId: item.workflow_id } }); + + return ( + + + {t('projects.newProjectDialog.title')} + + + + + + + + {t('projects.newProjectDialog.startWith')} + + {LAUNCHPAD_START_ENTRIES.map((start) => ( + + ))} + + + + {t('projects.newProjectDialog.from')} + + + + + + + + {t(entry.labelKey)} + + {t(entry.descriptionKey)} + + {needsModel ? ( + hasNoModels ? ( + } + title={t('projects.newProjectDialog.noModelsTitle')} + > + {canManageModels ? ( + + ) : null} + + ) : ( + + + + ) + ) : draft.intent === 'workflow' ? ( + <> + + + + ) : ( + + )} + + + {/* Only the footer is a form: the panes hold buttons of their own that must never submit it. */} + + + + { + const name = event.currentTarget.value; + + setDraft((current) => ({ ...current, name })); + }} + /> + + + {createBlockedReason ? ( + + {createBlockedReason} + + ) : null} + {canRetryCapabilities ? ( + + ) : null} + + + + + + + + + ); +}; + +export const NewProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { + const { canManageModels } = useCapabilities(); + const enableModelDescriptions = useWorkbenchPreferenceSelector((preferences) => preferences.enableModelDescriptions); + const selectedStartRef = useRef(null); + // Land on the selected start, so arrow keys and Enter work before anything else is touched. + const getInitialFocusEl = useCallback(() => selectedStartRef.current, []); + // No project is open in the Launchpad: the model picker's manager link needs no project to return to. + const modelsUiAdapter = useMemo( + () => ({ canManageModels, enableModelDescriptions, isProjectActive: () => true, managerProjectId: null }), + [canManageModels, enableModelDescriptions] + ); + const handleOpenChange = useCallback( + (event: Dialog.OpenChangeDetails) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + return ( + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.test.ts b/invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.test.ts new file mode 100644 index 00000000000..6980b2a62d2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.test.ts @@ -0,0 +1,243 @@ +import type { GalleryImage } from '@features/gallery'; +import type { ModelConfig } from '@features/models'; + +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; +import { describe, expect, it } from 'vitest'; + +import { + buildNewProjectRequest, + commitDimension, + createNewProjectDraft, + getActiveAspectRatio, + listSelectableGenerateModels, + resolveNewProjectModel, + resolveNewProjectSize, + selectAspectRatio, + selectModel, + setCanvasStart, + setStartImage, + swapDimensions, +} from './newProjectDraft'; + +seedArchitectureCapabilities(); + +const model = (overrides: Partial & Pick): ModelConfig => ({ + base: 'sdxl', + file_size: 1, + format: 'checkpoint', + hash: `hash-${overrides.key}`, + path: `/models/${overrides.key}`, + source: overrides.key, + source_type: 'path', + type: 'main', + ...overrides, +}); + +const sdxlPortrait = model({ + default_settings: { height: 1216, width: 832 }, + key: 'sdxl-portrait', + name: 'Zeta Portrait XL', +}); +const sdxlPlain = model({ key: 'sdxl-plain', name: 'Alpha XL' }); +const sd1 = model({ base: 'sd-1', key: 'sd1', name: 'Dreamshaper' }); +const flux = model({ base: 'flux', format: 'diffusers', key: 'flux-dev', name: 'FLUX Dev' }); +const lora = model({ format: 'lycoris', key: 'lora', name: 'Lora', type: 'lora' }); +const unsupportedMain = model({ base: 'sdxl-refiner', key: 'refiner', name: 'Refiner' }); + +const catalog = [lora, sdxlPortrait, unsupportedMain, sd1, flux, sdxlPlain]; +const selectable = listSelectableGenerateModels(catalog); + +describe('listSelectableGenerateModels', () => { + it('keeps only generate-capable mains, sorted by name', () => { + expect(selectable.map((entry) => entry.key)).toEqual(['sdxl-plain', 'sd1', 'flux-dev', 'sdxl-portrait']); + }); +}); + +describe('resolveNewProjectModel', () => { + it('prefers the session pick, then the remembered model, then the first selectable one', () => { + const draft = createNewProjectDraft('generate'); + + expect(resolveNewProjectModel(draft, selectable, null)?.key).toBe('sdxl-plain'); + expect(resolveNewProjectModel(draft, selectable, 'sd1')?.key).toBe('sd1'); + expect(resolveNewProjectModel(draft, selectable, 'uninstalled')?.key).toBe('sdxl-plain'); + expect(resolveNewProjectModel({ ...draft, modelKey: 'sdxl-plain' }, selectable, 'sd1')?.key).toBe('sdxl-plain'); + expect(resolveNewProjectModel(draft, [], 'sd1')).toBeNull(); + }); +}); + +describe('size', () => { + const draft = createNewProjectDraft('generate'); + const portrait = selectable.find((entry) => entry.key === 'sdxl-portrait')!; + const sdxl = selectable.find((entry) => entry.key === 'sdxl-plain')!; + const fluxDev = selectable.find((entry) => entry.key === 'flux-dev')!; + const sd1Model = selectable.find((entry) => entry.key === 'sd1')!; + + it('follows the model defaults until the user sets a size', () => { + expect(resolveNewProjectSize(draft, portrait)).toEqual({ height: 1216, width: 832 }); + expect(resolveNewProjectSize(draft, sd1Model)).toEqual({ height: 512, width: 512 }); + expect(resolveNewProjectSize(selectModel(draft, portrait), sdxl)).toEqual({ height: 1024, width: 1024 }); + }); + + it('reshapes the model pixel budget to a chosen ratio', () => { + // SDXL: 1024² at 3:2 → 1254×836, snapped to the 8px grid. + expect(resolveNewProjectSize(selectAspectRatio(draft, '3:2', sdxl), sdxl)).toEqual({ height: 840, width: 1256 }); + expect(resolveNewProjectSize(selectAspectRatio(draft, '16:9', sdxl), sdxl)).toEqual({ height: 768, width: 1368 }); + // SD 1: 512² at 3:2 → 627×418, snapped to 8. + expect(resolveNewProjectSize(selectAspectRatio(draft, '3:2', sd1Model), sd1Model)).toEqual({ + height: 416, + width: 624, + }); + }); + + it('keeps a typed size across a model change, re-snapped to the new grid', () => { + const typed = commitDimension(commitDimension(draft, sdxl, 'width', 1256), sdxl, 'height', 840); + + // 1256×840 on FLUX's 16px grid rounds up to 1264×848. + expect(resolveNewProjectSize(selectModel(typed, fluxDev), fluxDev)).toEqual({ height: 848, width: 1264 }); + }); + + it('carries a chip across a model change at the new model’s budget, chip still lit', () => { + const sd1Chip = selectAspectRatio(draft, '3:2', sd1Model); + + expect(resolveNewProjectSize(sd1Chip, sd1Model)).toEqual({ height: 416, width: 624 }); + + const moved = selectModel(sd1Chip, sdxl); + + expect(resolveNewProjectSize(moved, sdxl)).toEqual({ height: 840, width: 1256 }); + expect(getActiveAspectRatio(moved, resolveNewProjectSize(moved, sdxl))).toBe('3:2'); + }); + + it('snaps typed dimensions to the grid within the bounds and ignores non-numbers', () => { + expect(resolveNewProjectSize(commitDimension(draft, sdxl, 'width', 1003), sdxl)).toEqual({ + height: 1024, + width: 1000, + }); + expect(resolveNewProjectSize(commitDimension(draft, sdxl, 'height', 70_000), sdxl).height).toBe(4096); + expect(resolveNewProjectSize(commitDimension(draft, sdxl, 'height', 10), sdxl).height).toBe(64); + expect(commitDimension(draft, sdxl, 'width', Number.NaN)).toBe(draft); + }); + + it('swaps width and height and moves the active chip to the inverse ratio', () => { + const landscape = selectAspectRatio(draft, '3:2', sdxl); + const swapped = swapDimensions(landscape, sdxl); + + expect(resolveNewProjectSize(swapped, sdxl)).toEqual({ height: 1256, width: 840 }); + expect(getActiveAspectRatio(landscape, resolveNewProjectSize(landscape, sdxl))).toBe('3:2'); + expect(getActiveAspectRatio(swapped, resolveNewProjectSize(swapped, sdxl))).toBe('2:3'); + }); + + it('lights the chip a default or typed size matches exactly, and none otherwise', () => { + expect(getActiveAspectRatio(draft, resolveNewProjectSize(draft, sdxl))).toBe('1:1'); + expect(getActiveAspectRatio(draft, resolveNewProjectSize(draft, portrait))).toBeNull(); + + const typed = commitDimension(selectAspectRatio(draft, '3:2', sdxl), sdxl, 'width', 1000); + + expect(getActiveAspectRatio(typed, resolveNewProjectSize(typed, sdxl))).toBeNull(); + expect(resolveNewProjectSize(typed, sdxl)).toEqual({ height: 840, width: 1000 }); + + const square = commitDimension(typed, sdxl, 'height', 1000); + + expect(getActiveAspectRatio(square, resolveNewProjectSize(square, sdxl))).toBe('1:1'); + }); +}); + +describe('buildNewProjectRequest', () => { + const sdxl = selectable.find((entry) => entry.key === 'sdxl-plain')!; + + it('carries the model defaults with the chosen size for Generate and Canvas starts', () => { + const draft = selectAspectRatio({ ...createNewProjectDraft('canvas'), name: ' Portraits ' }, '3:2', sdxl); + const request = buildNewProjectRequest({ draft, model: sdxl, models: catalog }); + + expect(request.intent).toBe('canvas'); + expect(request.name).toBe('Portraits'); + expect(request.generateSettings).toMatchObject({ + aspectRatioId: '3:2', + aspectRatioIsLocked: false, + height: 840, + modelKey: 'sdxl-plain', + scheduler: 'euler_a', + steps: 30, + width: 1256, + }); + expect(request.generateSettings?.aspectRatioValue).toBe(1.5); + }); + + it('derives the ratio from a typed size', () => { + const draft = commitDimension(createNewProjectDraft('generate'), sdxl, 'width', 1000); + const { generateSettings } = buildNewProjectRequest({ draft, model: sdxl, models: catalog }); + + expect(generateSettings).toMatchObject({ aspectRatioId: 'Free', height: 1024, width: 1000 }); + expect(generateSettings?.aspectRatioValue).toBeCloseTo(1000 / 1024); + }); + + it('keeps the default draft name for a blank name and no settings for other starts', () => { + expect(buildNewProjectRequest({ draft: createNewProjectDraft('workflow'), model: sdxl, models: catalog })).toEqual({ + generateSettings: null, + intent: 'workflow', + libraryWorkflow: null, + name: null, + startImage: null, + }); + expect( + buildNewProjectRequest({ draft: createNewProjectDraft('generate'), model: null, models: [] }).generateSettings + ).toBeNull(); + }); + + it('sizes a Canvas start to its image, snapped to the grid, and carries the image', () => { + const image: GalleryImage = { + boardId: 'none', + height: 750, + imageCategory: 'user', + imageName: 'pasted.png', + imageUrl: '/images/pasted.png', + queuedAt: '', + sourceQueueItemId: '', + starred: false, + thumbnailUrl: '/thumbs/pasted.png', + width: 1000, + }; + const withImage = setStartImage(selectAspectRatio(createNewProjectDraft('canvas'), '3:2', sdxl), image); + + expect(resolveNewProjectSize(withImage, sdxl)).toEqual({ height: 752, width: 1000 }); + expect(getActiveAspectRatio(withImage, resolveNewProjectSize(withImage, sdxl))).toBeNull(); + const request = buildNewProjectRequest({ draft: withImage, model: sdxl, models: catalog }); + + // The ratio follows the image itself (1000×750 is exactly 4:3), not the snapped render size. + expect(request).toMatchObject({ + generateSettings: { aspectRatioId: '4:3', aspectRatioValue: 4 / 3, height: 752, width: 1000 }, + startImage: image, + }); + + // Blank forgets the image and returns to the size chosen before it. + const blank = setCanvasStart(withImage, 'blank'); + + expect(blank.startImage).toBeNull(); + expect(resolveNewProjectSize(blank, sdxl)).toEqual({ height: 840, width: 1256 }); + expect(buildNewProjectRequest({ draft: blank, model: sdxl, models: catalog }).startImage).toBeNull(); + + // The image belongs to Canvas starts: a Generate start ignores it. + const generate = { ...withImage, intent: 'generate' as const }; + + expect(resolveNewProjectSize(generate, sdxl)).toEqual({ height: 840, width: 1256 }); + expect(buildNewProjectRequest({ draft: generate, model: sdxl, models: catalog }).startImage).toBeNull(); + }); + + it('carries a picked library workflow for Workflow starts only', () => { + const libraryWorkflow = { name: 'Upscale pipeline', workflowId: 'lib-1' }; + + expect( + buildNewProjectRequest({ + draft: { ...createNewProjectDraft('workflow'), libraryWorkflow }, + model: null, + models: [], + }) + ).toMatchObject({ generateSettings: null, intent: 'workflow', libraryWorkflow }); + expect( + buildNewProjectRequest({ + draft: { ...createNewProjectDraft('generate'), libraryWorkflow }, + model: sdxl, + models: catalog, + }).libraryWorkflow + ).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.ts b/invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.ts new file mode 100644 index 00000000000..188653a7d39 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/newProjectDraft.ts @@ -0,0 +1,261 @@ +import type { GalleryImage } from '@features/gallery'; +import type { AspectRatioId, GenerateModelConfig } from '@features/generation/contracts'; +import type { ModelConfig } from '@features/models'; +import type { LaunchpadIntentId } from '@workbench/launchpad/intents'; +import type { NewProjectRequest } from '@workbench/projects/newProjectRequest'; + +import { + ASPECT_RATIO_MAP, + calculateNewSize, + clampDimension, + deriveAspectRatioId, + getDefaultGenerateSettings, + getGenerateModelSelectionResult, + getGenerationDimensions, + isGenerateModelSelectable, +} from '@features/generation/settings'; + +/** + * The New-project dialog's choices, kept apart from the models and capabilities that arrive while it is open: the + * model and size are resolved against the current catalog at render time, so a late-loading catalog or a model that + * was uninstalled since the last visit never leaves the form pointing at nothing. + */ + +export type NewProjectModel = ModelConfig & GenerateModelConfig; + +export interface Dimensions { + height: number; + width: number; +} + +export interface NewProjectDraft { + intent: LaunchpadIntentId; + name: string; + /** A model the user picked this session; null follows the remembered model, then the first selectable one. */ + modelKey: string | null; + /** A size the user set this session; null follows the resolved model's defaults. */ + customSize: Dimensions | null; + /** The chip the size came from; null once a typed size no longer matches one, or while following the defaults. */ + aspectRatioId: NewProjectAspectRatioId | null; + /** The library workflow a Workflow start opens with; null starts from the blank one. */ + libraryWorkflow: { workflowId: string; name: string } | null; + /** Whether a Canvas start opens blank or on an image. */ + canvasStart: 'blank' | 'image'; + /** The image a Canvas start opens on; the canvas and the size follow its dimensions while one is chosen. */ + startImage: GalleryImage | null; +} + +/** Offered as chips: the everyday ratios, widest first within each orientation. */ +export const NEW_PROJECT_ASPECT_RATIOS = [ + '1:1', + '3:2', + '2:3', + '16:9', + '9:16', + '4:3', + '3:4', +] as const satisfies readonly AspectRatioId[]; + +export type NewProjectAspectRatioId = (typeof NEW_PROJECT_ASPECT_RATIOS)[number]; + +/** Generate and Canvas projects start from a model and a size; the other starts choose their inputs in the editor. */ +export const hasGenerationSettings = (intent: LaunchpadIntentId): boolean => + intent === 'generate' || intent === 'canvas'; + +export const createNewProjectDraft = (intent: LaunchpadIntentId): NewProjectDraft => ({ + aspectRatioId: null, + customSize: null, + canvasStart: 'blank', + intent, + libraryWorkflow: null, + modelKey: null, + name: '', + startImage: null, +}); + +/** A chosen start image fixes the size: the canvas is sized to it, so the chips and inputs rest. */ +export const getStartImage = (draft: NewProjectDraft): GalleryImage | null => + draft.intent === 'canvas' && draft.canvasStart === 'image' ? draft.startImage : null; + +/** Switching back to Blank forgets the image, so Image starts empty again next time. */ +export const setCanvasStart = (draft: NewProjectDraft, canvasStart: 'blank' | 'image'): NewProjectDraft => ({ + ...draft, + canvasStart, + startImage: canvasStart === 'image' ? draft.startImage : null, +}); + +export const setStartImage = (draft: NewProjectDraft, startImage: GalleryImage | null): NewProjectDraft => ({ + ...draft, + canvasStart: 'image', + startImage, +}); + +/** Installed models a Generate project can start from, by name; the first one is the fallback selection. */ +export const listSelectableGenerateModels = (models: readonly ModelConfig[]): NewProjectModel[] => + models + .filter((model): model is NewProjectModel => isGenerateModelSelectable(model)) + .sort((left, right) => left.name.localeCompare(right.name)); + +/** The model the form shows: the session's pick, else the remembered one, else the first selectable; null when none. */ +export const resolveNewProjectModel = ( + draft: NewProjectDraft, + selectableModels: readonly NewProjectModel[], + rememberedModelKey: string | null +): NewProjectModel | null => + (draft.modelKey === null ? undefined : selectableModels.find((model) => model.key === draft.modelKey)) ?? + (rememberedModelKey === null ? undefined : selectableModels.find((model) => model.key === rememberedModelKey)) ?? + selectableModels[0] ?? + null; + +const getModelDefaultSize = (model: NewProjectModel | null): Dimensions => { + const { height, width } = getDefaultGenerateSettings(model ?? undefined); + + return { height, width }; +}; + +const snapToModel = (size: Dimensions, model: NewProjectModel | null): Dimensions => { + const { grid } = getGenerationDimensions(model ?? undefined); + + return { height: clampDimension(size.height, grid), width: clampDimension(size.width, grid) }; +}; + +/** + * The size the form shows: a chosen start image's dimensions on the model's grid (what the canvas bbox sync derives), + * else the session's custom size, else the resolved model's defaults. + */ +export const resolveNewProjectSize = (draft: NewProjectDraft, model: NewProjectModel | null): Dimensions => { + const startImage = getStartImage(draft); + + if (startImage) { + return snapToModel({ height: startImage.height, width: startImage.width }, model); + } + + return draft.customSize ?? getModelDefaultSize(model); +}; + +/** + * A size from a chip follows the new model (its budget at the same ratio); a typed size stays, re-snapped to the new + * grid; a default size follows the new model's defaults. + */ +export const selectModel = (draft: NewProjectDraft, model: NewProjectModel): NewProjectDraft => { + const next = { ...draft, modelKey: model.key }; + + if (draft.aspectRatioId !== null && draft.customSize) { + return selectAspectRatio(next, draft.aspectRatioId, model); + } + + return { ...next, customSize: draft.customSize ? snapToModel(draft.customSize, model) : null }; +}; + +/** The offered chip matching a size exactly; snapped chip sizes rarely do, so chip picks are remembered instead. */ +const getOfferedAspectRatio = (size: Dimensions): NewProjectAspectRatioId | null => { + const ratioId = deriveAspectRatioId(size.width, size.height); + + return NEW_PROJECT_ASPECT_RATIOS.find((candidate) => candidate === ratioId) ?? null; +}; + +/** Reshapes the model's optimal pixel budget to the ratio; unlike the editor's picker, which keeps the current area. */ +export const selectAspectRatio = ( + draft: NewProjectDraft, + ratioId: NewProjectAspectRatioId, + model: NewProjectModel | null +): NewProjectDraft => { + const { grid, optimal } = getGenerationDimensions(model ?? undefined); + + return { + ...draft, + aspectRatioId: ratioId, + customSize: calculateNewSize(ASPECT_RATIO_MAP[ratioId].ratio, optimal * optimal, grid), + }; +}; + +/** Typed values snap to the model's grid within the dimension bounds; a non-number leaves the draft alone. */ +export const commitDimension = ( + draft: NewProjectDraft, + model: NewProjectModel | null, + key: keyof Dimensions, + value: number +): NewProjectDraft => { + if (!Number.isFinite(value)) { + return draft; + } + + const { grid } = getGenerationDimensions(model ?? undefined); + const customSize = { ...resolveNewProjectSize(draft, model), [key]: clampDimension(value, grid) }; + + return { ...draft, aspectRatioId: getOfferedAspectRatio(customSize), customSize }; +}; + +export const swapDimensions = (draft: NewProjectDraft, model: NewProjectModel | null): NewProjectDraft => { + const { height, width } = resolveNewProjectSize(draft, model); + const customSize = { height: width, width: height }; + const inverseId = draft.aspectRatioId === null ? null : ASPECT_RATIO_MAP[draft.aspectRatioId].inverseId; + + return { + ...draft, + aspectRatioId: + NEW_PROJECT_ASPECT_RATIOS.find((candidate) => candidate === inverseId) ?? getOfferedAspectRatio(customSize), + customSize, + }; +}; + +/** The chip to show active: the one picked, else the one a default, typed or image size matches exactly. */ +export const getActiveAspectRatio = (draft: NewProjectDraft, size: Dimensions): NewProjectAspectRatioId | null => + getStartImage(draft) === null ? (draft.aspectRatioId ?? getOfferedAspectRatio(size)) : getOfferedAspectRatio(size); + +/** + * Generate and Canvas starts carry the chosen model's defaults with the chosen size over them, with the dependent + * component selections (VAE, encoders) resolved against the catalog the way picking the model in the editor would. + */ +export const buildNewProjectRequest = ({ + draft, + model, + models, +}: { + draft: NewProjectDraft; + model: NewProjectModel | null; + models: readonly ModelConfig[]; +}): NewProjectRequest => { + const name = draft.name.trim(); + const libraryWorkflow = draft.intent === 'workflow' ? draft.libraryWorkflow : null; + const startImage = getStartImage(draft); + + if (!hasGenerationSettings(draft.intent) || model === null) { + return { generateSettings: null, intent: draft.intent, libraryWorkflow, name: name || null, startImage }; + } + + const { height, width } = resolveNewProjectSize(draft, model); + const { settings } = getGenerateModelSelectionResult({ + currentValues: getDefaultGenerateSettings(model), + model, + models, + }); + + // An image start takes its ratio from the image's exact dimensions (what the bbox sync derives from the canvas), + // whatever chip was picked before it; the snapped size is what generation renders at. + const ratioSource = startImage ? { height: startImage.height, width: startImage.width } : { height, width }; + const aspectRatioId: AspectRatioId = + startImage === null + ? (draft.aspectRatioId ?? deriveAspectRatioId(width, height)) + : deriveAspectRatioId(ratioSource.width, ratioSource.height); + + return { + generateSettings: { + ...settings, + aspectRatioId, + aspectRatioIsLocked: false, + aspectRatioValue: + aspectRatioId === 'Free' + ? ratioSource.height > 0 + ? ratioSource.width / ratioSource.height + : 1 + : ASPECT_RATIO_MAP[aspectRatioId].ratio, + height, + width, + }, + intent: draft.intent, + libraryWorkflow, + name: name || null, + startImage, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index a96bdc93e79..54de303dd08 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -16,7 +16,7 @@ Projects autosave their workflows. Library changes only through explicit publica Canvas uploads carrying a project id await that identity’s acknowledged server creation through the mounted persistence service. Uploads waiting on an unacknowledged project share one serialized creation attempt; a failed attempt answers further uploads for a few seconds before another is made; later uploads use the acknowledged identity without flushing unrelated edits. Only a closed project, account change, engine disposal or the operation's own cancellation stops an upload, and those stop the wait immediately. Any other outcome (creation failed or offline, conflict, or server that no longer knows the project and refuses the id) sends the upload without project id. Such uploads are never re-associated with the project later, so project's intermediates cleanup does not see them. -Project links are consumed after successful open, so reload restores the saved active project, not an earlier link. New-project startup selects the newly created draft before saving the session. +Project links are consumed after successful open, so reload restores the saved active project, not an earlier link. New-project startup selects the newly created draft before saving the session. The Launchpad's New-project dialog stages its extra choices (name, generate settings, library workflow, start image) in a memory-only one-shot request (`newProjectRequest.ts`) that the session controller consumes once after applying the URL intent to the fresh draft, dropping a request staged for another start; a reload before that opens the plain intent draft. For a Canvas start the controller sizes the canvas document itself and hands a start image on through `canvasStartImage.ts` for the Canvas widget to place once it mounts; a reload between the two leaves a sized, empty canvas. Workflow reads the active workflow through the App-composed port and edits, publishes, presents documents; it never replaces authored project work. Library is written only by the Workflow-owned publication controller, which captures account, project, workflow and content before start, refuses to overlap itself per workflow, reconciles a lost answer against the destination record before resending, and records resulting library id and revision on the originating workflow alone. Queue submissions carry the originating project workflow so seed advances, node progress and run previews return to it whatever is active later. diff --git a/invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.test.ts b/invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.test.ts new file mode 100644 index 00000000000..5d94f88a6fe --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.test.ts @@ -0,0 +1,56 @@ +import type { GalleryImage } from '@features/gallery'; + +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestCanvasStartImage, subscribeCanvasStartImage, takeCanvasStartImage } from './canvasStartImage'; + +const image: GalleryImage = { + boardId: 'none', + height: 750, + imageCategory: 'user', + imageName: 'pasted.png', + imageUrl: '/images/pasted.png', + queuedAt: '', + sourceQueueItemId: '', + starred: false, + thumbnailUrl: '/thumbs/pasted.png', + width: 1000, +}; + +beforeEach(() => { + accountLifecycle.activate('user-a', ':user:user-a'); +}); + +afterEach(() => { + accountLifecycle.invalidate(); +}); + +describe('canvasStartImage', () => { + it('hands the image to its project once and to no other project', () => { + requestCanvasStartImage({ image, projectId: 'draft' }); + + expect(takeCanvasStartImage('other')).toBeNull(); + expect(takeCanvasStartImage('draft')).toBe(image); + expect(takeCanvasStartImage('draft')).toBeNull(); + }); + + it('notifies subscribers so a widget already mounted can place the image', () => { + const listener = vi.fn(); + const unsubscribe = subscribeCanvasStartImage(listener); + + requestCanvasStartImage({ image, projectId: 'draft' }); + + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it('drops a staged image when the account changes', () => { + requestCanvasStartImage({ image, projectId: 'draft' }); + + accountLifecycle.invalidate(); + accountLifecycle.activate('user-b', ':user:user-b'); + + expect(takeCanvasStartImage('draft')).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.ts b/invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.ts new file mode 100644 index 00000000000..6c4e8019347 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/canvasStartImage.ts @@ -0,0 +1,40 @@ +import type { GalleryImage } from '@features/gallery'; + +import { registerAccountOwnedResource } from '@platform/state/accountLifecycle'; +import { createExternalStore } from '@platform/state/externalStore'; + +/** + * The image a new Canvas project opens on, staged by the session controller for the Canvas widget to lay down once + * it mounts. The controller sizes the canvas itself; placing the image needs the gallery import path, which lives + * with the widget rather than in the editor's boot graph. Keyed by project so a request never lands elsewhere. + */ +export interface CanvasStartImageRequest { + image: GalleryImage; + projectId: string; +} + +const store = createExternalStore<{ request: CanvasStartImageRequest | null }>({ request: null }); + +registerAccountOwnedResource({ + clear: () => store.setSnapshot({ request: null }), + name: 'canvas-start-image', +}); + +export const requestCanvasStartImage = (request: CanvasStartImageRequest): void => { + store.setSnapshot({ request }); +}; + +/** Returns and clears the request staged for `projectId`; null when none, or when it belongs to another project. */ +export const takeCanvasStartImage = (projectId: string): GalleryImage | null => { + const { request } = store.getSnapshot(); + + if (request === null || request.projectId !== projectId) { + return null; + } + + store.setSnapshot({ request: null }); + + return request.image; +}; + +export const subscribeCanvasStartImage = store.subscribe; diff --git a/invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.test.ts b/invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.test.ts new file mode 100644 index 00000000000..2c1cf8c6e2c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.test.ts @@ -0,0 +1,47 @@ +import { accountLifecycle } from '@platform/state/accountLifecycle'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { requestNewProject, takeNewProjectRequest, type NewProjectRequest } from './newProjectRequest'; + +const request = (name: string | null = 'Portraits'): NewProjectRequest => ({ + generateSettings: { height: 840, width: 1256 }, + intent: 'canvas', + libraryWorkflow: null, + name, + startImage: null, +}); + +beforeEach(() => { + accountLifecycle.activate('user-a', ':user:user-a'); +}); + +afterEach(() => { + accountLifecycle.invalidate(); +}); + +describe('newProjectRequest', () => { + it('hands the staged request out exactly once', () => { + const staged = request(); + + requestNewProject(staged); + + expect(takeNewProjectRequest()).toBe(staged); + expect(takeNewProjectRequest()).toBeNull(); + }); + + it('keeps only the latest request', () => { + requestNewProject(request('First')); + requestNewProject(request('Second')); + + expect(takeNewProjectRequest()?.name).toBe('Second'); + }); + + it('drops a staged request when the account changes', () => { + requestNewProject(request()); + + accountLifecycle.invalidate(); + accountLifecycle.activate('user-b', ':user:user-b'); + + expect(takeNewProjectRequest()).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.ts b/invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.ts new file mode 100644 index 00000000000..502defed77b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/newProjectRequest.ts @@ -0,0 +1,45 @@ +import type { GalleryImage } from '@features/gallery'; +import type { GenerateSettings } from '@features/generation/contracts'; +import type { LaunchpadIntentId } from '@workbench/launchpad/intents'; + +import { registerAccountOwnedResource } from '@platform/state/accountLifecycle'; + +/** + * What the Launchpad's New-project dialog chose beyond the URL intent. The dialog stages it just before navigating to + * `/app?new=true&intent=…`; the session controller takes it once, after applying the intent to the fresh draft. + * Memory-only on purpose: a reload in between opens the plain intent draft, and a stale choice never resurfaces on a + * later new-project link. + */ +export interface NewProjectRequest { + intent: LaunchpadIntentId; + /** Trimmed and nonempty; null keeps the default draft name. */ + name: string | null; + /** Generate settings to commit after the intent is applied; null keeps the draft's defaults. */ + generateSettings: Partial | null; + /** A library workflow to load over the draft's blank workflow; null keeps the blank one. */ + libraryWorkflow: { workflowId: string; name: string } | null; + /** An image a Canvas start opens on, as its first raster layer with the canvas sized to it; null starts blank. */ + startImage: GalleryImage | null; +} + +let pending: NewProjectRequest | null = null; + +registerAccountOwnedResource({ + clear: () => { + pending = null; + }, + name: 'new-project-request', +}); + +export const requestNewProject = (request: NewProjectRequest): void => { + pending = request; +}; + +/** Returns and clears the pending request; null when none. */ +export const takeNewProjectRequest = (): NewProjectRequest | null => { + const request = pending; + + pending = null; + + return request; +}; diff --git a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts index 5d130227b74..10dbeedd1a3 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts @@ -3,6 +3,7 @@ import type { LogLevel, LogNamespace } from '@platform/logging/contracts'; import type { SettingsTarget } from '@platform/ui/settings/contracts'; import type { PromptFontSize } from '@theme/scale'; import type { WorkbenchThemeId } from '@theme/themes'; +import type { LaunchpadIntentId } from '@workbench/launchpad/intents'; import type { ProjectSortId, ProjectsViewId } from '@workbench/launchpad/projects/projectLibraryView'; export type { ProjectSortId, ProjectsViewId } from '@workbench/launchpad/projects/projectLibraryView'; @@ -100,6 +101,10 @@ export interface WorkbenchPreferences { launchpadProjectsSort: ProjectSortId; /** Projects pinned to the top of the library, oldest pin first. */ launchpadPinnedProjectIds: string[]; + /** The start the New-project dialog preselects: the one last created from. */ + launchpadNewProjectIntent: LaunchpadIntentId; + /** The model the New-project dialog last created with; null until one was chosen. */ + launchpadNewProjectModelKey: string | null; /** User-saved Krea-2 conditioning rebalance curves, in the order they were saved. */ krea2RebalancePresets: StoredRebalancePreset[]; /** User-saved Generate settings snapshots ("recipes"), in the order they were saved. */ diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts index b04734654e1..c07d76da8c3 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts @@ -473,6 +473,33 @@ describe('normalizeWorkbenchPreferences queue scope', () => { }); }); +describe('normalizeWorkbenchPreferences new-project dialog', () => { + it('starts from Generate with no remembered model', () => { + const preferences = store.normalizeWorkbenchPreferences({}); + + expect(preferences.launchpadNewProjectIntent).toBe('generate'); + expect(preferences.launchpadNewProjectModelKey).toBeNull(); + }); + + it('keeps a known start and a nonempty model key, healing anything else', () => { + expect( + store.normalizeWorkbenchPreferences({ + launchpadNewProjectIntent: 'canvas', + launchpadNewProjectModelKey: 'sdxl-1', + }) + ).toMatchObject({ launchpadNewProjectIntent: 'canvas', launchpadNewProjectModelKey: 'sdxl-1' }); + expect( + store.normalizeWorkbenchPreferences({ + launchpadNewProjectIntent: 'paint', + launchpadNewProjectModelKey: '' as never, + }) + ).toMatchObject({ launchpadNewProjectIntent: 'generate', launchpadNewProjectModelKey: null }); + expect( + store.normalizeWorkbenchPreferences({ launchpadNewProjectModelKey: 7 as never }).launchpadNewProjectModelKey + ).toBeNull(); + }); +}); + describe('normalizeWorkbenchPreferences generate sections', () => { it('defaults to no section overrides', () => { expect(store.normalizeWorkbenchPreferences({}).generateSectionsOpen).toEqual({}); diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.ts b/invokeai/frontend/webv2/src/workbench/settings/store.ts index 3e13c1dd05b..e0fd84b133a 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.ts @@ -22,6 +22,7 @@ import { createExternalStore } from '@platform/state/externalStore'; import { createSingleFlight } from '@platform/state/singleFlight'; import { isPromptFontSize } from '@theme/scale'; import { DEFAULT_THEME_ID, resolveWorkbenchThemeId } from '@theme/themes'; +import { isLaunchpadIntentId } from '@workbench/launchpad/intents'; import { deleteClientStateValue, getClientStateValue, setClientStateValue } from '@workbench/projects/api'; import { fetchSessionBlob } from '@workbench/projects/session'; @@ -60,6 +61,8 @@ export const DEFAULT_PREFERENCES: WorkbenchPreferences = { highContrast: false, krea2RebalancePresets: [], language: 'en', + launchpadNewProjectIntent: 'generate', + launchpadNewProjectModelKey: null, launchpadPinnedProjectIds: [], launchpadProjectsSort: 'edited', launchpadProjectsView: 'grid', @@ -273,8 +276,13 @@ export const normalizeProjectSettings = (settings?: Partial): P type WorkbenchPreferencesInput = Omit< Partial, - 'launchpadProjectsSort' | 'launchpadProjectsView' | 'queueJobsScope' | 'workflowEdgeStyle' + | 'launchpadNewProjectIntent' + | 'launchpadProjectsSort' + | 'launchpadProjectsView' + | 'queueJobsScope' + | 'workflowEdgeStyle' > & { + launchpadNewProjectIntent?: unknown; launchpadProjectsSort?: unknown; launchpadProjectsView?: unknown; queueJobsScope?: unknown; @@ -319,6 +327,13 @@ export const normalizeWorkbenchPreferences = (preferences?: WorkbenchPreferences generateSectionsOpen: normalizeGenerateSectionsOpen(preferences?.generateSectionsOpen), krea2RebalancePresets: normalizeRebalancePresets(preferences?.krea2RebalancePresets), language: normalizeWorkbenchLanguage(preferences?.language) ?? DEFAULT_PREFERENCES.language, + launchpadNewProjectIntent: isLaunchpadIntentId(preferences?.launchpadNewProjectIntent) + ? preferences.launchpadNewProjectIntent + : DEFAULT_PREFERENCES.launchpadNewProjectIntent, + launchpadNewProjectModelKey: + typeof preferences?.launchpadNewProjectModelKey === 'string' && preferences.launchpadNewProjectModelKey.length > 0 + ? preferences.launchpadNewProjectModelKey + : DEFAULT_PREFERENCES.launchpadNewProjectModelKey, launchpadPinnedProjectIds: normalizePinnedProjectIds(preferences?.launchpadPinnedProjectIds), launchpadProjectsSort: isProjectSortId(preferences?.launchpadProjectsSort) ? preferences.launchpadProjectsSort diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.browser.test.tsx index 633da74c91a..33d13017745 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.browser.test.tsx @@ -8,6 +8,7 @@ import type * as WorkbenchContextModule from '@workbench/WorkbenchContext'; import { ChakraProvider } from '@chakra-ui/react'; import { system } from '@theme/system'; +import { getDocumentLeaves } from '@workbench/canvas-engine/api'; import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; import { createCanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; @@ -63,8 +64,23 @@ vi.mock('./MissingFontsDialog', () => ({ MissingFontsDialog: () => null })); vi.mock('./ToolStrip', () => ({ ToolStrip: () => null })); vi.mock('@workbench/widgets/layers/LayerContextMenu', () => ({ CanvasLayerContextMenu: () => null })); +import { requestCanvasStartImage, takeCanvasStartImage } from '@workbench/projects/canvasStartImage'; + import { CanvasWidgetView } from './CanvasWidgetView'; +const START_IMAGE = { + boardId: 'none', + height: 750, + imageCategory: 'user' as const, + imageName: 'pasted.png', + imageUrl: '/images/pasted.png', + queuedAt: '', + sourceQueueItemId: '', + starred: false, + thumbnailUrl: '/thumbs/pasted.png', + width: 1000, +}; + const i18n = createInstance(); let root: Root | null = null; let host: HTMLDivElement | null = null; @@ -79,6 +95,9 @@ afterEach(async () => { await act(() => root?.unmount()); host?.remove(); harness.engine?.lifecycle.dispose(); + if (harness.store) { + takeCanvasStartImage(activeProject().id); + } Object.assign(harness, { engine: null, store: null }); root = null; host = null; @@ -96,8 +115,8 @@ const candidate: CanvasStagingCandidateContract = { width: 64, }; -/** Stages the first image of a three-image batch whose remaining images are `status`. */ -const renderView = async (status: QueueItem['status']) => { +/** Stages the first image of a three-image batch whose remaining images are `status`; `prepare` runs before mount. */ +const renderView = async (status: QueueItem['status'], prepare?: (projectId: string) => void) => { const initial = createInitialWorkbenchState(); const base = initial.projects[0]!; const batch = { @@ -120,6 +139,7 @@ const renderView = async (status: QueueItem['status']) => { store.commands.canvas.appendStagingCandidate({ candidate, projectId }); store.commands.canvas.apply(projectId, { imageIndex: 0, type: 'setStagedImageIndex' }); harness.store = store; + prepare?.(projectId); harness.engine = createCanvasEngine({ backend: createTestStubRasterBackend(), ensureProjectOnServer: () => Promise.resolve(), @@ -174,3 +194,41 @@ describe('CanvasWidgetView accept and stop', () => { expect(project.queue.items[0]?.status).toBe('completed'); }); }); + +describe('CanvasWidgetView start image', () => { + /** The raster layer the start image became, if any. */ + const startImageLayer = () => + getDocumentLeaves(activeProject().canvas.document).find( + (layer) => + layer.type === 'raster' && layer.source.type === 'image' && layer.source.image.imageName === 'pasted.png' + ); + + it('lays a staged start image down for the active project through the reducer path and stays quiet', async () => { + const notify = vi.fn(); + const { projectId } = await renderView('completed', (id) => { + requestCanvasStartImage({ image: START_IMAGE, projectId: id }); + // Installed before mount, so a notice raised during placement cannot slip past the assertion. + harness.store!.commands.notifications.add = notify; + }); + + await vi.waitFor(() => expect(startImageLayer()).toBeDefined()); + expect(getDocumentLeaves(activeProject().canvas.document).filter((layer) => layer.type === 'raster')).toHaveLength( + 1 + ); + expect(takeCanvasStartImage(projectId)).toBeNull(); + await act(async () => {}); + expect(notify).not.toHaveBeenCalled(); + }); + + it('leaves another project’s image alone and places one staged after mount through its subscription', async () => { + requestCanvasStartImage({ image: START_IMAGE, projectId: 'someone-else' }); + + const { projectId } = await renderView('completed'); + + await act(async () => {}); + expect(startImageLayer()).toBeUndefined(); + + await act(() => requestCanvasStartImage({ image: START_IMAGE, projectId })); + await vi.waitFor(() => expect(startImageLayer()).toBeDefined()); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx index b98d8923931..67668603a48 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx @@ -4,14 +4,16 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; import { Box } from '@chakra-ui/react'; import { useDndMonitor, type DragEndEvent } from '@dnd-kit/core'; import { useQueueItemProgressImage } from '@features/queue/react'; +import { readClipboardImage } from '@platform/browser/clipboardImage'; import { useMountEffect } from '@platform/react/useMountEffect'; import { preloadCanvasInvocation } from '@workbench/activeInvocationSubmission'; -import { getCanvasImportNotice } from '@workbench/canvas-operations/api'; +import { getCanvasEngine, getCanvasImportNotice, importGalleryImagesToCanvas } from '@workbench/canvas-operations/api'; import { useCanvasEngine, type CanvasEngineHandle } from '@workbench/canvas-operations/react'; import { getCanvasStagingSlots } from '@workbench/canvasStagingView'; import { useRegisterShortcutHintSource } from '@workbench/hotkeys/hintSources'; import { recordCanvasImportError } from '@workbench/image-actions/canvasImportError'; import { readLayerPanelState } from '@workbench/layerPanelState'; +import { subscribeCanvasStartImage, takeCanvasStartImage } from '@workbench/projects/canvasStartImage'; import { useWorkbenchSettingsSelector } from '@workbench/settings/store'; import { useCanvasProjectMutationDispatch } from '@workbench/useCanvasProjectMutationDispatch'; import { useNotify } from '@workbench/useNotify'; @@ -29,7 +31,7 @@ import { useTranslation } from 'react-i18next'; import { useModelGridSize } from './bboxGrid'; import { CanvasBottomOverlay } from './CanvasBottomOverlay'; -import { copyBlobToClipboard, decodeImageBlob, readClipboardImage } from './canvasClipboard'; +import { copyBlobToClipboard, decodeImageBlob } from './canvasClipboard'; import { resolveCanvasContextMenu, resolveCanvasContextMenuBranch, @@ -128,6 +130,49 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => { // Warm the split invocation chunk when Canvas mounts so the first submission need not download it. useMountEffect(preloadCanvasInvocation); + // A new Canvas project started from an image: the session controller sized the canvas and staged the image for + // this widget. The live engine places it once there is one (read at call time, as the image actions do); before + // that the reducer path does, as a canvas created from gallery images is. Only a troubled import is reported. + useMountEffect(() => { + const placeStartImage = () => { + const project = queries.getSnapshot().activeProject; + const image = takeCanvasStartImage(project.id); + + if (!image) { + return; + } + + void importGalleryImagesToCanvas({ + applyCanvasMutation: canvasCommands.apply, + destination: 'raster', + engine: getCanvasEngine(project.id) ?? null, + getProject: queries.getProject, + images: [image], + isActiveProject: queries.isActiveProject, + project, + }) + .then((result) => { + if (result.status !== 'imported' || result.failedImageNames.length > 0) { + const notice = getCanvasImportNotice(result); + + notifications.add({ kind: notice.kind, title: t(notice.titleKey, notice.options ?? {}) }); + } + }) + .catch((error: unknown) => { + recordCanvasImportError({ + error, + localizedMessage: t('widgets.canvas.import.failed'), + notifications, + projectId: project.id, + }); + }); + }; + + placeStartImage(); + + return subscribeCanvasStartImage(placeStartImage); + }); + // Right-click on the canvas surface targets the selected layer (the panel is // the sole authority; the stack is never hit-tested) and opens either the // shared per-layer menu or the global menu at the pointer. diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.test.ts index 989e717e215..7c9ebd6e89d 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.test.ts @@ -1,63 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import { decodeImageBlob, readClipboardImage } from './canvasClipboard'; +import { decodeImageBlob } from './canvasClipboard'; const blob = (type: string): Blob => ({ type }) as Blob; -/** A ClipboardItem stand-in exposing just what the reader touches. */ -const item = (types: string[], payload: Record = {}): ClipboardItem => - ({ - getType: (type: string) => Promise.resolve(payload[type] ?? blob(type)), - types, - }) as unknown as ClipboardItem; - -describe('readClipboardImage', () => { - it('returns null when the clipboard API is unavailable', async () => { - await expect(readClipboardImage({ clipboard: undefined })).resolves.toBeNull(); - }); - - it('returns null when permission is refused rather than throwing', async () => { - // A paste that cannot read the clipboard is an ordinary outcome — a - // defocused document refuses too — so it must not surface as an error. - const clipboard = { read: () => Promise.reject(new Error('NotAllowedError')) }; - await expect(readClipboardImage({ clipboard })).resolves.toBeNull(); - }); - - it('returns null when the clipboard holds no image', async () => { - const clipboard = { read: () => Promise.resolve([item(['text/plain'])]) }; - await expect(readClipboardImage({ clipboard })).resolves.toBeNull(); - }); - - it('reads a PNG off the clipboard', async () => { - const png = blob('image/png'); - const clipboard = { read: () => Promise.resolve([item(['image/png'], { 'image/png': png })]) }; - await expect(readClipboardImage({ clipboard })).resolves.toBe(png); - }); - - it('prefers PNG over lossy types when several are offered', async () => { - const png = blob('image/png'); - const jpeg = blob('image/jpeg'); - const clipboard = { - read: () => Promise.resolve([item(['image/jpeg', 'image/png'], { 'image/jpeg': jpeg, 'image/png': png })]), - }; - await expect(readClipboardImage({ clipboard })).resolves.toBe(png); - }); - - it('falls back to a lossy type when PNG is absent', async () => { - const jpeg = blob('image/jpeg'); - const clipboard = { read: () => Promise.resolve([item(['image/jpeg'], { 'image/jpeg': jpeg })]) }; - await expect(readClipboardImage({ clipboard })).resolves.toBe(jpeg); - }); - - it('scans past a non-image item to a later image', async () => { - const png = blob('image/png'); - const clipboard = { - read: () => Promise.resolve([item(['text/plain']), item(['image/png'], { 'image/png': png })]), - }; - await expect(readClipboardImage({ clipboard })).resolves.toBe(png); - }); -}); - describe('decodeImageBlob', () => { const imageData = { data: new Uint8ClampedArray(4), height: 1, width: 1 } as ImageData; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.ts index c39ecd11a45..0de958f9887 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasClipboard.ts @@ -1,45 +1,10 @@ /** * Bridge clipboard images and engine Blob/ImageData on the widget side; inject dependencies for tests without real - * clipboard/canvas access. + * canvas access. */ export { copyBlobToClipboard } from '@workbench/widgets/layers/layerExportActions'; -type ClipboardReadLike = { read(): Promise }; - -export interface ReadClipboardImageDeps { - clipboard?: ClipboardReadLike; -} - -/** MIME types accepted from the clipboard, best first. */ -const IMAGE_TYPES = ['image/png', 'image/webp', 'image/jpeg'] as const; - -/** - * The first image on the clipboard, or `null` when it holds none. Returns - * `null` rather than throwing when the clipboard is unavailable or permission - * was refused — a paste that finds nothing is an ordinary outcome, not an error. - */ -export const readClipboardImage = async (deps: ReadClipboardImageDeps = {}): Promise => { - const clipboard = deps.clipboard ?? (globalThis.navigator?.clipboard as ClipboardReadLike | undefined); - if (!clipboard?.read) { - return null; - } - let items: readonly ClipboardItem[]; - try { - items = await clipboard.read(); - } catch { - // Permission refused, or the document is not focused. Nothing to paste. - return null; - } - for (const item of items) { - const type = IMAGE_TYPES.find((candidate) => item.types.includes(candidate)); - if (type) { - return item.getType(type); - } - } - return null; -}; - export interface DecodeImageBlobDeps { createImageBitmap?: (blob: Blob) => Promise; createCanvas?: (width: number, height: number) => HTMLCanvasElement | OffscreenCanvas; diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index 2c6072eb156..ab47427dffc 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -53,6 +53,12 @@ const ROUTE_SHARED_MODULES = [ '/workbench/projects/editorSession.ts', '/platform/ui/BrandIcon.tsx', '/platform/ui/Button.tsx', + // Every route's forms use them; lazily loaded dialogs on both routes do too, and alone each split into a boot request. + '/platform/ui/Field.tsx', + '/platform/ui/hints/FeatureHint.tsx', + '/platform/ui/hints/hintRegistry.ts', + '/platform/ui/hints/hintsContext.tsx', + '/platform/ui/hints/index.ts', // The Fonts page, the Generate render section and the lazy workflow number fields share the slider controls; // alone they cost the editor a boot request, and the editor boot chunk would drag the editor into the Launchpad. '/platform/ui/Slider.tsx', @@ -98,6 +104,12 @@ const ROUTE_SHARED_MODULES = [ '/workbench/launchpad/formatRelativeTime.ts', // Without this the editor pulls the whole Launchpad chunk for one lookup table. '/workbench/launchpad/intents.ts', + // The Launchpad's New-project dialog stages it; the editor's session controller consumes it. + '/workbench/projects/newProjectRequest.ts', + // The Launchpad's New-project dialog and the lazy Canvas widget both paste from it. + '/platform/browser/clipboardImage.ts', + // The Launchpad's node manager, the workflow editor and the lazy publication host share this one-component menu item. + '/features/workflow/ui/MenuActionItem.tsx', '/workbench/mediaReferences.ts', '/workbench/palette/settingsEntryDeps.ts', '/workbench/projects/covers.ts', @@ -124,6 +136,10 @@ const EDITOR_BOOT_SHARED_MODULES = [ '/workbench/hotkeys/shortcutHints.ts', '/workbench/hotkeys/targetWidget.ts', '/workbench/widget-frame/compactWidgetCapacity.tsx', + // The session controller stages a new Canvas project's start image here; the lazy Canvas widget takes it. + '/workbench/projects/canvasStartImage.ts', + // The widget chrome asks it at boot; the lazily loaded library and publication dialogs ask it too. + '/features/workflow/core/workflowSource.ts', '/features/gallery/ui/GalleryItemSearch.tsx', '/app/GalleryUiAdapter.tsx', '/features/generation/core/prompt/ast.ts',