feat(ui): implement generalized textarea size tracking system

This commit is contained in:
psychedelicious
2025-05-30 12:31:24 +10:00
parent a589dec122
commit 4835c344b3
3 changed files with 121 additions and 11 deletions

View File

@@ -0,0 +1,108 @@
import { useAppStore } from 'app/store/nanostores/store';
import type { Dimensions } from 'features/controlLayers/store/types';
import { selectUiSlice, textAreaSizesStateChanged } from 'features/ui/store/uiSlice';
import { debounce } from 'lodash-es';
import { type RefObject, useCallback, useEffect, useMemo } from 'react';
type Options = {
trackWidth: boolean;
trackHeight: boolean;
initialWidth?: number;
initialHeight?: number;
};
/**
* Persists the width and/or height of a text area to redux.
* @param id The unique id of this textarea, used as key to storage
* @param ref A ref to the textarea element
* @param options.trackWidth Whether to track width
* @param options.trackHeight Whether to track width
* @param options.initialWidth An optional initial width in pixels
* @param options.initialHeight An optional initial height in pixels
*/
export const usePersistedTextAreaSize = (id: string, ref: RefObject<HTMLTextAreaElement>, options: Options) => {
const { dispatch, getState } = useAppStore();
const onResize = useCallback(
(size: Partial<Dimensions>) => {
dispatch(textAreaSizesStateChanged({ id, size }));
},
[dispatch, id]
);
const debouncedOnResize = useMemo(() => debounce(onResize, 300), [onResize]);
useEffect(() => {
const el = ref.current;
if (!el) {
return;
}
// Nothing to do here if we are not tracking anything.
if (!options.trackHeight && !options.trackWidth) {
return;
}
// Before registering the observer, grab the stored size from state - we may need to restore the size.
const storedSize = selectUiSlice(getState()).textAreaSizes[id];
// Prefer to restore the stored size, falling back to initial size if it exists
if (storedSize?.width !== undefined) {
el.style.width = `${storedSize.width}px`;
} else if (options.initialWidth !== undefined) {
el.style.width = `${options.initialWidth}px`;
}
if (storedSize?.height !== undefined) {
el.style.height = `${storedSize.height}px`;
} else if (options.initialHeight !== undefined) {
el.style.height = `${options.initialHeight}px`;
}
let currentHeight = el.offsetHeight;
let currentWidth = el.offsetWidth;
const resizeObserver = new ResizeObserver(() => {
// We only want to push the changes if a tracked dimension changes
let didChange = false;
const newSize: Partial<Dimensions> = {};
if (options.trackHeight) {
if (el.offsetHeight !== currentHeight) {
didChange = true;
currentHeight = el.offsetHeight;
}
newSize.height = currentHeight;
}
if (options.trackWidth) {
if (el.offsetWidth !== currentWidth) {
didChange = true;
currentWidth = el.offsetWidth;
}
newSize.width = currentWidth;
}
if (didChange) {
debouncedOnResize(newSize);
}
});
resizeObserver.observe(el);
return () => {
debouncedOnResize.cancel();
resizeObserver.disconnect();
};
}, [
debouncedOnResize,
dispatch,
getState,
id,
options.initialHeight,
options.initialWidth,
options.trackHeight,
options.trackWidth,
ref,
]);
};

View File

@@ -2,6 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { createSelector, createSlice } from '@reduxjs/toolkit';
import type { PersistConfig, RootState } from 'app/store/store';
import { newSessionRequested } from 'features/controlLayers/store/actions';
import type { Dimensions } from 'features/controlLayers/store/types';
import { workflowLoaded } from 'features/nodes/store/nodesSlice';
import { atom } from 'nanostores';
@@ -15,8 +16,8 @@ const initialUIState: UIState = {
shouldShowProgressInViewer: true,
accordions: {},
expanders: {},
textAreaSizes: {},
shouldShowNotificationV2: true,
positivePromptBoxHeight: 40,
};
export const uiSlice = createSlice({
@@ -43,12 +44,13 @@ export const uiSlice = createSlice({
const { id, isOpen } = action.payload;
state.expanders[id] = isOpen;
},
textAreaSizesStateChanged: (state, action: PayloadAction<{ id: string; size: Partial<Dimensions> }>) => {
const { id, size } = action.payload;
state.textAreaSizes[id] = size;
},
shouldShowNotificationChanged: (state, action: PayloadAction<boolean>) => {
state.shouldShowNotificationV2 = action.payload;
},
positivePromptBoxHeightChanged: (state, action: PayloadAction<number>) => {
state.positivePromptBoxHeight = action.payload;
},
},
extraReducers(builder) {
builder.addCase(workflowLoaded, (state) => {
@@ -68,7 +70,7 @@ export const {
accordionStateChanged,
expanderStateChanged,
shouldShowNotificationChanged,
positivePromptBoxHeightChanged,
textAreaSizesStateChanged,
} = uiSlice.actions;
export const selectUiSlice = (state: RootState) => state.ui;
@@ -105,5 +107,3 @@ const TABS_WITH_RIGHT_PANEL: TabName[] = ['canvas', 'upscaling', 'workflows'] as
export const RIGHT_PANEL_MIN_SIZE_PX = 390;
export const $isRightPanelOpen = atom(true);
export const selectWithRightPanel = createSelector(selectUiSlice, (ui) => TABS_WITH_RIGHT_PANEL.includes(ui.activeTab));
export const selectPositivePromptBoxHeight = createSelector(selectUiSlice, (ui) => ui.positivePromptBoxHeight);

View File

@@ -1,3 +1,5 @@
import type { Dimensions } from 'features/controlLayers/store/types';
export type TabName = 'canvas' | 'upscaling' | 'workflows' | 'models' | 'queue';
export type CanvasRightPanelTabName = 'layers' | 'gallery';
@@ -30,12 +32,12 @@ export interface UIState {
* The state of expanders. The key is the id of the expander, and the value is a boolean representing the open state.
*/
expanders: Record<string, boolean>;
/**
* The size of textareas. The key is the id of the text area, and the value is an object representing its width and/or height.
*/
textAreaSizes: Record<string, Partial<Dimensions>>;
/**
* Whether or not to show the user the open notification. Bump version to reset users who may have closed previous version.
*/
shouldShowNotificationV2: boolean;
/**
* The height of the positive prompt box.
*/
positivePromptBoxHeight: number;
}