Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 101 additions & 25 deletions invokeai/frontend/webv2/src/workbench/floatingWidgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('floatWidget', () => {
expect(floating?.queue.x).toBeGreaterThan(floating?.gallery.x ?? 0);
});

it('refuses to float the last center view, which would blank the work surface', () => {
it('floats the last center view, leaving the surface to its fallback view', () => {
let state = createInitialWorkbenchState();
const centerInstanceIds = getActiveProject(state).widgetRegions.center.instanceIds;

Expand All @@ -74,7 +74,52 @@ describe('floatWidget', () => {

const lastCenterInstanceId = centerInstanceIds[0];
expect(getActiveProject(state).widgetRegions.center.instanceIds).toEqual([lastCenterInstanceId]);
expect(workbenchReducer(state, { instanceId: lastCenterInstanceId, type: 'floatWidget' })).toBe(state);

state = workbenchReducer(state, { instanceId: lastCenterInstanceId, type: 'floatWidget' });
const center = getActiveProject(state).widgetRegions.center;

// The surface may go empty — the fallback view carries it — and the
// window remembers the center as its dock-back target. The active pointer
// holds the floated instance so docking restores it as the active view,
// and the center takes no collapsed state.
expect(center.instanceIds).toEqual([]);
expect(center.activeInstanceId).toBe(lastCenterInstanceId);
expect(center.isCollapsed).toBe(false);
expect(getActiveProject(state).floatingWidgets?.[lastCenterInstanceId]).toMatchObject({
returnRegion: 'center',
});
});

it('docks back into the region the float was asked from, not the first member region', () => {
// The Edit preset places the preview in the center AND the right rail, and
// `right` precedes `center` in the region map — without the origin hint,
// floating it from the center chrome would detach the rail's membership
// and dock it back there.
let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'edit', type: 'applyPreset' });
state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' });
const project = getActiveProject(state);

expect(project.widgetRegions.center.instanceIds).not.toContain('preview');
expect(project.widgetRegions.right.instanceIds).toContain('preview');
expect(project.floatingWidgets?.preview).toMatchObject({ returnRegion: 'center' });

const docked = workbenchReducer(state, { instanceId: 'preview', type: 'dockFloatingWidget' });

expect(getActiveProject(docked).widgetRegions.center.instanceIds).toContain('preview');
expect(getActiveProject(docked).widgetRegions.center.activeInstanceId).toBe('preview');
});

it('falls back to a member region when the float carries no region hint', () => {
let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'edit', type: 'applyPreset' });
state = workbenchReducer(state, { instanceId: 'preview', type: 'floatWidget' });
const project = getActiveProject(state);
const floating = project.floatingWidgets?.preview;

// The hint is what makes dock-back deterministic; without it some member
// region still hosts the float — which one is region map order, not a
// contract.
expect(floating).toBeDefined();
expect(['center', 'right']).toContain(floating!.returnRegion);
});

it('collapses a rail it empties instead of leaving it open and blank', () => {
Expand Down Expand Up @@ -239,6 +284,19 @@ describe('interaction with region placement', () => {
expect(project.floatingWidgets?.gallery).toBeUndefined();
expect(project.widgetRegions.right.instanceIds).toContain('gallery');
});

it('selectRegionWidget docks a floating instance instead of double-rendering it', () => {
// The preview is still a member of the right rail while it floats in a
// window docked back to the center; selecting its rail slot would
// otherwise make the rail show it alongside the window.
let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'edit', type: 'applyPreset' });
state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' });
state = workbenchReducer(state, { region: 'right', type: 'selectRegionWidget', widgetId: 'preview' });
const project = getActiveProject(state);

expect(project.floatingWidgets?.preview).toBeUndefined();
expect(project.widgetRegions.right.activeInstanceId).toBe('preview');
});
});

describe('pure helpers', () => {
Expand Down Expand Up @@ -337,30 +395,32 @@ describe('normalization of persisted floating windows', () => {
});
});

it('docks rather than empties the center region', () => {
const project = getActiveProject(createInitialWorkbenchState());
const [onlyCenterInstanceId] = project.widgetRegions.center.instanceIds;
const normalized = normalizeWorkbenchProject({
...project,
floatingWidgets: {
[onlyCenterInstanceId]: {
heightPx: 300,
mode: 'windowed',
returnRegion: 'center',
stackOrder: 1,
widthPx: 400,
x: 10,
y: 10,
},
},
widgetRegions: {
...project.widgetRegions,
center: { ...project.widgetRegions.center, instanceIds: [onlyCenterInstanceId] },
},
});
it('lets a sole center view keep floating across a reload', () => {
// Floating the last center view empties the work surface into its fallback
// view, and the window's dock control is one click from restoring it. The
// persisted shape is an EMPTY center whose active pointer names the floated
// instance — normalization must honour it, not read it as missing center
// data and refill the default arrangement (which would inject views the
// project never placed).
let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'video', type: 'applyPreset' });
state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' });
const project = getActiveProject(state);
expect(project.widgetRegions.center.instanceIds).toEqual([]);

expect(normalized.floatingWidgets).toBeUndefined();
expect(normalized.widgetRegions.center.instanceIds).toEqual([onlyCenterInstanceId]);
// The reducer output is exactly what autosave persists.
const normalized = normalizeWorkbenchProject(JSON.parse(JSON.stringify(project)));

expect(normalized.floatingWidgets?.preview).toMatchObject({ returnRegion: 'center' });
expect(normalized.widgetRegions.center.instanceIds).toEqual([]);
expect(normalized.widgetRegions.center.activeInstanceId).toBe('preview');

// Docking after the reload restores the view.
const docked = workbenchReducer(
{ ...state, projects: state.projects.map((p) => (p.id === normalized.id ? normalized : p)) },
{ instanceId: 'preview', type: 'dockFloatingWidget' }
);

expect(getActiveProject(docked).widgetRegions.center.instanceIds).toEqual(['preview']);
});
});

Expand Down Expand Up @@ -393,6 +453,22 @@ describe('interaction with presets and undo', () => {
expect(getRegionsHolding(project, 'gallery')).toEqual([]);
});

it('a preset saved with the sole center view floating restores the emptied surface, not a phantom view', () => {
// Normalizing before the snapshot must not refill the emptied center with
// the default arrangement — the saved preset would otherwise carry center
// views the project never placed.
let state = workbenchReducer(createInitialWorkbenchState(), { presetId: 'video', type: 'applyPreset' });
state = workbenchReducer(state, { instanceId: 'preview', region: 'center', type: 'floatWidget' });
state = workbenchReducer(state, { presetId: 'video', type: 'saveLayoutPreset' });
// Dock it, then revert to the preset that was saved with it floating.
state = workbenchReducer(state, { instanceId: 'preview', type: 'dockFloatingWidget' });
state = workbenchReducer(state, { presetId: 'video', type: 'applyPreset' });
const project = getActiveProject(state);

expect(project.floatingWidgets?.preview).toBeDefined();
expect(project.widgetRegions.center.instanceIds).toEqual([]);
});

it('carries a saved preset’s floating window through account rehydration', () => {
// Account presets are rebuilt by `cloneLayoutPresetSnapshot` on every load;
// a field it forgets is a field the preset loses on the next reload.
Expand Down
5 changes: 3 additions & 2 deletions invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ describe('built-in layout preset descriptors', () => {
center: ['canvas', 'preview'],
left: ['generate', 'upscale'],
panels: { isBottomOpen: false, isLeftOpen: true, isRightOpen: true },
// The Edit rail is the Layers panel alone; its editors are panes inside it.
right: ['layers'],
// The editors are panes inside the Layers panel; the preview docks
// behind Layers so its float/dock surface stays reachable by default.
right: ['layers', 'preview'],
},
video: {
active: { bottom: 'gallery:bottom', center: 'preview', left: 'video', right: 'gallery' },
Expand Down
5 changes: 4 additions & 1 deletion invokeai/frontend/webv2/src/workbench/layoutPresets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [
}),
right: createRegion({
activeInstanceId: 'layers',
instanceIds: ['layers'],
// Preview docks behind Layers: the editors are Layers panes, but the
// preview is a floatable widget, and its float/dock pair and floated
// Invoke control are only reachable where it is actually placed.
instanceIds: ['layers', 'preview'],
sizePx: 450,
}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

/**
* The float control replaced a menu item, so the conditions that used to hide
* that item now decide whether a button renders at all: a widget that may not
* float, and the last center view — floating it out would leave the work
* surface empty. Docking is covered by the floating window's own chrome.
* that item now decide whether a button renders at all: only a widget whose
* manifest forbids floating, and the floating region itself. Even the last
* center view may float — the emptied surface falls back to the center's
* fallback view until the window docks back. Docking is covered by the
* floating window's own chrome.
*/

const floatMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -119,7 +121,7 @@ describe('WidgetFloatButton', () => {
});

expect(floatMocks.flushWorkbenchDrafts).toHaveBeenCalled();
expect(floatMocks.float).toHaveBeenCalledWith('image-map-instance');
expect(floatMocks.float).toHaveBeenCalledWith('image-map-instance', 'right');
expect(floatMocks.flushWorkbenchDrafts.mock.invocationCallOrder[0]).toBeLessThan(
floatMocks.float.mock.invocationCallOrder[0]
);
Expand All @@ -129,8 +131,8 @@ describe('WidgetFloatButton', () => {
expect(await renderButton('right', false)).toBeNull();
});

it('renders nothing for the last enabled center view', async () => {
expect(await renderButton('center')).toBeNull();
it('renders for the last enabled center view — the emptied surface falls back until it docks back', async () => {
expect(await renderButton('center')).not.toBeNull();
});

it('renders for a center view that is not the last one', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,16 @@ import { useMountEffect } from '@platform/react/useMountEffect';
import { IconButton } from '@platform/ui/Button';
import { Tooltip } from '@platform/ui/Tooltip';
import { useFocusRegionProps } from '@workbench/focusRegions';
import { isWidgetRegion } from '@workbench/layoutContracts';
import { openWorkbenchSettings } from '@workbench/settings/settingsDialogStore';
import { resolveWidgetInstanceLabel } from '@workbench/widgetLabels';
import { getEnabledCenterViewCount } from '@workbench/widgetPlacementCommands';
import { areWidgetPlacementProjectsEqual, getWidgetPlacementProject } from '@workbench/widgetPlacementMeta';
import { useActiveProjectSelector, useWorkbenchCommands } from '@workbench/WorkbenchContext';
import {
clampPanelSize,
getPanelSizeBounds,
getVisiblePanelCollapseThreshold,
shouldSnapPanelShutAt,
} from '@workbench/workbenchState';
import { useWorkbenchWidgetRegistry } from '@workbench/WorkbenchWidgetRegistryContext';
import { PictureInPicture2Icon, SettingsIcon } from 'lucide-react';
import {
useCallback,
Expand Down Expand Up @@ -310,23 +308,29 @@ export const WidgetFloatButton = ({
region: WorkbenchRegion;
}) => {
const { t } = useTranslation();
const placementProject = useActiveProjectSelector(getWidgetPlacementProject, areWidgetPlacementProjectsEqual);
const { getWidgetById } = useWorkbenchWidgetRegistry();
const { widgets } = useWorkbenchCommands();
// A dialog or popover's chrome never floats; anything else is a dockable
// layout region, which is also the dock-back target.
const dockableRegion = isWidgetRegion(region) ? region : undefined;
// Floating unmounts the docked subtree; the draft registry's cleanup only
// deregisters the flusher, so an uncommitted edit is lost without this.
// deregisters the flusher, so an uncommitted edit is lost without this. The
// region rides along: one instance may be placed in several regions (the
// preview lives in the center and a rail), and the window docks back into
// the one whose button was clicked.
const handleFloat = useCallback(() => {
if (!dockableRegion) {
return;
}

flushWorkbenchDrafts();
widgets.float(instanceId);
}, [instanceId, widgets]);
widgets.float(instanceId, dockableRegion);
}, [dockableRegion, instanceId, widgets]);
// Floating is offered only from dockable regions; the floating window's own
// chrome carries the dock control. The last center *view* is not offered it
// either — floating it out would leave the work surface with nothing to
// show, which is why `closeWidgetPlacement` refuses the same removal.
const canFloat =
Boolean(manifest.allowFloating) &&
region !== 'floating' &&
!(region === 'center' && getEnabledCenterViewCount(placementProject, getWidgetById) === 1);
// chrome carries the dock control. Even the last center *view* may float:
// the emptied surface falls back to the center's fallback view, and the
// window's dock control restores it — only the destructive placements
// (`closeWidgetPlacement`, `toggleRegionWidget`) still refuse that.
const canFloat = Boolean(manifest.allowFloating) && dockableRegion !== undefined;

if (!canFloat) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ const createDispatch = () => {

const widgets: WorkbenchWidgetCommands = {
dockFloating: (instanceId) => dispatch({ instanceId, type: 'dockFloatingWidget' }),
float: (instanceId) => dispatch({ instanceId, type: 'floatWidget' }),
float: (instanceId, region) =>
dispatch(region ? { instanceId, region, type: 'floatWidget' } : { instanceId, type: 'floatWidget' }),
focusFloating: (instanceId) => dispatch({ instanceId, type: 'focusFloatingWidget' }),
move: (options) => dispatch({ ...options, type: 'moveWidgetInstance' }),
setAlignment: (options) => dispatch({ ...options, type: 'setWidgetInstanceAlignment' }),
Expand Down
15 changes: 13 additions & 2 deletions invokeai/frontend/webv2/src/workbench/workbenchState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ describe('workbench widget region defaults', () => {
}
});

it('adopts the Layers-only Edit rail for untouched legacy rails and leaves customized rails alone', () => {
it('adopts the shipped Edit rail for untouched legacy rails and leaves customized rails alone', () => {
const initial = createInitialWorkbenchState();
const withRight = (instanceIds: Project['widgetRegions']['right']['instanceIds']): WorkbenchState => ({
...initial,
Expand All @@ -575,14 +575,25 @@ describe('workbench widget region defaults', () => {
})),
});
const legacyEditRail = withRight(['layers', 'preview', 'gallery', 'image-map', 'queue']);
const layersOnlyRail = withRight(['layers']);
const custom = withRight(['image-map', 'layers']);

const hydratedLegacy = getActiveProject(
workbenchReducer(initial, { state: legacyEditRail, type: 'hydrateWorkbench' })
);
expect(hydratedLegacy.widgetRegions.right).toMatchObject({
activeInstanceId: 'layers',
instanceIds: ['layers'],
instanceIds: ['layers', 'preview'],
});

// The brief Layers-only rail (which dropped the preview) is a shipped
// shape too, so it adopts the current rail and gets the preview back.
const hydratedLayersOnly = getActiveProject(
workbenchReducer(initial, { state: layersOnlyRail, type: 'hydrateWorkbench' })
);
expect(hydratedLayersOnly.widgetRegions.right).toMatchObject({
activeInstanceId: 'layers',
instanceIds: ['layers', 'preview'],
});

const hydratedCustom = getActiveProject(workbenchReducer(initial, { state: custom, type: 'hydrateWorkbench' }));
Expand Down
Loading
Loading