diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.test.ts b/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.test.ts index 331e52e5fe1..e015af3ea50 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.test.ts @@ -51,8 +51,9 @@ describe('useNdvLayout', () => { localStorage.setItem(key, JSON.stringify({ left: 0, main: 5, right: 0 })); const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType }); - expect(panelWidthPercentage.value.left).toBeCloseTo(12); - expect(panelWidthPercentage.value.right).toBeCloseTo(12); + expect(panelWidthPercentage.value.left).toBeGreaterThanOrEqual(12); + expect(panelWidthPercentage.value.right).toBeGreaterThanOrEqual(12); + expect(panelWidthPercentage.value.main).toBeCloseTo(36.8); }); it('updates layout on resize (left)', () => { @@ -109,4 +110,67 @@ describe('useNdvLayout', () => { expect(panelWidthPercentage.value.main).toBeCloseTo(42); expect(panelWidthPercentage.value.right).toBeCloseTo(29); }); + + describe('when the stored layout cannot be used as-is', () => { + const totalOf = ({ left, main, right }: { left: number; main: number; right: number }) => + left + main + right; + + it('spans the full container when stored values fall below the minimums', () => { + containerWidth.value = 1317; + const key = `${LOCAL_STORAGE_NDV_PANEL_WIDTH}_REGULAR`; + localStorage.setItem(key, JSON.stringify({ left: 1, main: 1, right: 1 })); + + const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType }); + + // Minimums alone only add up to 46% of the container, leaving the canvas visible behind. + expect(totalOf(panelWidthPercentage.value)).toBeCloseTo(100); + expect(panelWidthPercentage.value.left).toBeGreaterThanOrEqual((120 / 1317) * 100); + expect(panelWidthPercentage.value.right).toBeGreaterThanOrEqual((120 / 1317) * 100); + expect(panelWidthPercentage.value.main).toBeGreaterThanOrEqual((368 / 1317) * 100); + }); + + it('falls back to the defaults when the stored value is not usable', () => { + containerWidth.value = 1317; + const key = `${LOCAL_STORAGE_NDV_PANEL_WIDTH}_REGULAR`; + localStorage.setItem(key, JSON.stringify({ left: null, main: null, right: null })); + + const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType }); + + expect(panelWidthPercentage.value.main).toBeCloseTo((420 / 1317) * 100); + expect(totalOf(panelWidthPercentage.value)).toBeCloseTo(100); + }); + + it('keeps a usable layout while the container is unmeasured', () => { + containerWidth.value = 0; + + const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType }); + + expect(Object.values(panelWidthPercentage.value).every(Number.isFinite)).toBe(true); + expect(totalOf(panelWidthPercentage.value)).toBeCloseTo(100); + }); + + it('does not persist while the container is unmeasured', () => { + containerWidth.value = 0; + const spy = vi.spyOn(Storage.prototype, 'setItem'); + + const { onResizeEnd } = useNdvLayout({ container, hasInputPanel, paneType }); + onResizeEnd(); + + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('keeps the left panel collapsed for "inputless" layouts', () => { + containerWidth.value = 1317; + hasInputPanel.value = false; + paneType.value = 'inputless'; + const key = `${LOCAL_STORAGE_NDV_PANEL_WIDTH}_INPUTLESS`; + localStorage.setItem(key, JSON.stringify({ left: 0, main: 1, right: 1 })); + + const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType }); + + expect(panelWidthPercentage.value.left).toBe(0); + expect(totalOf(panelWidthPercentage.value)).toBeCloseTo(100); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.ts b/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.ts index a47fc0c6592..7917d76f88f 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.ts +++ b/packages/frontend/editor-ui/src/features/ndv/panel/composables/useNdvLayout.ts @@ -72,6 +72,12 @@ export function useNdvLayout(options: UseNdvLayoutOptions) { } }); + const isUsablePanelSize = (size: NdvPanelsSize | null | undefined): size is NdvPanelsSize => + !!size && + Number.isFinite(size.left) && + Number.isFinite(size.main) && + Number.isFinite(size.right); + const safePanelWidth = ({ left, main, right }: { left: number; main: number; right: number }) => { const hasInput = toValue(options.hasInputPanel); const minLeft = hasInput ? minPanelWidthPercentage.value : 0; @@ -85,32 +91,41 @@ export function useNdvLayout(options: UseNdvLayoutOptions) { }; const total = newPanelWidth.left + newPanelWidth.main + newPanelWidth.right; + const sides = newPanelWidth.left + newPanelWidth.right; - if (total > 100) { - const overflow = total - 100; + // Panels must always span the container: distribute any difference across the + // side panels, otherwise a short total leaves the canvas showing through. + if (total !== 100 && sides > 0) { + const diff = 100 - total; + const leftShare = newPanelWidth.left / sides; - const trimLeft = (newPanelWidth.left / (newPanelWidth.left + newPanelWidth.right)) * overflow; - const trimRight = overflow - trimLeft; - - newPanelWidth.left = Math.max(minLeft, newPanelWidth.left - trimLeft); - newPanelWidth.right = Math.max(minRight, newPanelWidth.right - trimRight); + newPanelWidth.left = Math.max(minLeft, newPanelWidth.left + diff * leftShare); + newPanelWidth.right = Math.max(minRight, newPanelWidth.right + diff * (1 - leftShare)); } return newPanelWidth; }; const persistPanelSize = () => { + // Before the container is measured the sizes are placeholders, not something + // the user chose — persisting them would overwrite their actual layout. + if (!containerWidth.value || !isUsablePanelSize(panelWidthPercentage.value)) return; + localStorage.setItem(localStorageKey.value, JSON.stringify(panelWidthPercentage.value)); }; const loadPanelSize = () => { + if (!containerWidth.value) return; + const storedPanelSizeString = localStorage.getItem(localStorageKey.value); const defaultSize = defaultPanelSize.value; if (storedPanelSizeString) { const storedPanelSize = jsonParse(storedPanelSizeString, { fallbackValue: defaultSize, }); - panelWidthPercentage.value = safePanelWidth(storedPanelSize ?? defaultSize); + panelWidthPercentage.value = safePanelWidth( + isUsablePanelSize(storedPanelSize) ? storedPanelSize : defaultSize, + ); } else { panelWidthPercentage.value = safePanelWidth(defaultSize); }