diff --git a/packages/web/src/utility/widgetBarResizing.ts b/packages/web/src/utility/widgetBarResizing.ts new file mode 100644 index 000000000..53ed77cf4 --- /dev/null +++ b/packages/web/src/utility/widgetBarResizing.ts @@ -0,0 +1,223 @@ +import _ from 'lodash'; +import is from 'zod/v4/locales/is.cjs'; + +export interface WidgetBarStoredProps { + contentHeight: number; + collapsed: boolean; +} + +export interface WidgetBarComputedProps { + contentHeight: number; + visibleItemsCount: number; + splitterVisible: boolean; + collapsed: boolean; + clickableTitle: boolean; +} + +export interface WidgetBarComputedResult { + [name: string]: WidgetBarComputedProps; +} + +export interface WidgetBarItemDefinition { + name: string; + height?: string; // e.g. '200px' or '30%' + collapsed: boolean; // initial value of collapsing status + skip: boolean; + minimalContentHeight: number; +} + +export type PushWidgetBarItemDefinitionFunction = (def: WidgetBarItemDefinition) => void; +export type UpdateWidgetBarItemDefinitionFunction = (name: string, def: Partial) => void; +export type ResizeWidgetItemFunction = (name: string, deltaY: number) => void; +export type ToggleCollapseWidgetItemFunction = (name: string) => void; + +export interface WidgetBarContainerProps { + clientHeight: number; + titleHeight: number; + splitterHeight: number; +} + +// accordeon mode - only one item can be expanded at a time +export function widgetShouldBeInAccordeonMode( + container: WidgetBarContainerProps, + definitions: WidgetBarItemDefinition[] +): boolean { + const visibleItems = definitions.filter(def => !def.skip); + + const availableContentHeight = + container.clientHeight - + visibleItems.length * container.titleHeight - + Math.max(0, visibleItems.length - 1) * container.splitterHeight; + + const minimalRequiredContentHeight = _.sum(visibleItems.map(def => def.minimalContentHeight)); + return availableContentHeight < minimalRequiredContentHeight; +} + +export function computeInitialWidgetBarProps( + container: WidgetBarContainerProps, + definitions: WidgetBarItemDefinition[], + currentProps: WidgetBarComputedResult +): WidgetBarComputedResult { + const visibleItems = definitions.filter(def => !def.skip); + const expandedItems = visibleItems.filter(def => currentProps[def.name]?.collapsed ?? def.collapsed); + const res: WidgetBarComputedResult = {}; + + const availableContentHeight = + container.clientHeight - + visibleItems.length * container.titleHeight - + Math.max(0, expandedItems.length - 1) * container.splitterHeight; + + if (widgetShouldBeInAccordeonMode(container, definitions)) { + // In accordeon mode, only the first expanded item is shown, others are collapsed + const expandedItem = visibleItems.find(def => !def.collapsed); + for (const def of visibleItems) { + const isExpanded = def.name === expandedItem?.name; + res[def.name] = { + contentHeight: isExpanded ? availableContentHeight : 0, + visibleItemsCount: visibleItems.length, + splitterVisible: false, + collapsed: !isExpanded, + clickableTitle: !isExpanded, + }; + } + return res; + } + + // First pass: calculate base heights + let totalContentHeight = 0; + let totalFlexibleItems = 0; + const itemHeights = {}; + + for (const def of expandedItems) { + if (def.height) { + let height = 0; + if (_.isString(def.height) && def.height.endsWith('px')) height = parseInt(def.height.slice(0, -2)); + else if (_.isString(def.height) && def.height.endsWith('%')) + height = (availableContentHeight * parseFloat(def.height.slice(0, -1))) / 100; + else height = parseInt(def.height); + if (height < def.minimalContentHeight) height = def.minimalContentHeight; + totalContentHeight += height; + itemHeights[def.name] = height; + } else { + totalFlexibleItems += 1; + } + } + + // Second pass - distribute remaining height + if (totalFlexibleItems > 0) { + let remainingHeight = availableContentHeight - totalContentHeight; + for (const def of expandedItems) { + if (def.height) { + let height = remainingHeight / totalFlexibleItems; + if (height < def.minimalContentHeight) height = def.minimalContentHeight; + itemHeights[def.name] = height; + } + } + } + + // Third pass - update heights to match available height + totalContentHeight = _.sum(Object.values(itemHeights)); + if (totalContentHeight != availableContentHeight) { + const scale = availableContentHeight / totalContentHeight; + for (const def of expandedItems) { + itemHeights[def.name] = itemHeights[def.name] * scale; + } + } + + // Final assembly of results + let visibleIndex = 0; + for (const def of visibleItems) { + res[def.name] = { + contentHeight: Math.round(itemHeights[def.name] || 0), + visibleItemsCount: visibleItems.length, + splitterVisible: visibleItems.length > 1 && visibleIndex < visibleItems.length - 1, + collapsed: !expandedItems.includes(def), + clickableTitle: true, + }; + visibleIndex += 1; + } + + return res; +} + +export function handleResizeWidgetBar( + container: WidgetBarContainerProps, + definitions: WidgetBarItemDefinition[], + currentProps: WidgetBarComputedResult, + resizedItemName: string, + deltaY: number +): WidgetBarComputedResult { + const res = _.cloneDeep(currentProps); + const visibleItems = definitions.filter(def => !def.skip); + const resizedItemDef = definitions.find(def => def.name === resizedItemName); + if (!resizedItemDef || resizedItemDef.collapsed) return res; + const resizedItemProps = res[resizedItemName]; + + if (deltaY < 0) { + // moving up - reduce height of resized item, if too small, reduce height of previous items + let remainingDeltaY = -deltaY; + let itemIndex = visibleItems.findIndex(def => def.name === resizedItemName); + while (remainingDeltaY > 0 && itemIndex >= 0) { + const itemDef = visibleItems[itemIndex]; + const itemProps = res[itemDef.name]; + const currentHeight = itemProps.contentHeight; + const minimalHeight = itemDef.minimalContentHeight; + const reducibleHeight = currentHeight - minimalHeight; + if (reducibleHeight > 0) { + const reduction = Math.min(reducibleHeight, remainingDeltaY); + itemProps.contentHeight -= reduction; + remainingDeltaY -= reduction; + } + itemIndex -= 1; + } + } else { + // moving down - increase height of resized item, reduce size of next item, if too small, reduce size of further items + // if all items below are at minimal height, stop + let remainingDeltaY = deltaY; + let itemIndex = visibleItems.findIndex(def => def.name === resizedItemName) + 1; + while (remainingDeltaY > 0 && itemIndex < visibleItems.length) { + const itemDef = visibleItems[itemIndex]; + const itemProps = res[itemDef.name]; + const currentHeight = itemProps.contentHeight; + const minimalHeight = itemDef.minimalContentHeight; + const reducibleHeight = currentHeight - minimalHeight; + if (reducibleHeight > 0) { + const reduction = Math.min(reducibleHeight, remainingDeltaY); + itemProps.contentHeight -= reduction; + resizedItemProps.contentHeight += reduction; + remainingDeltaY -= reduction; + } + itemIndex += 1; + } + } + return res; +} + +export function toggleCollapseWidgetBar( + container: WidgetBarContainerProps, + definitions: WidgetBarItemDefinition[], + currentProps: WidgetBarComputedResult, + toggledItemName: string +): WidgetBarComputedResult { + const visibleItems = definitions.filter(def => !def.skip); + + if (widgetShouldBeInAccordeonMode(container, definitions)) { + // In accordeon mode, only the first expanded item is shown, others are collapsed + const res: WidgetBarComputedResult = {}; + for (const def of visibleItems) { + const isExpanded = def.name === toggledItemName; + res[def.name] = { + contentHeight: undefined, + visibleItemsCount: visibleItems.length, + splitterVisible: false, + collapsed: !isExpanded, + clickableTitle: !isExpanded, + }; + } + return res; + } + + const res = _.cloneDeep(currentProps); + res[toggledItemName].collapsed = !res[toggledItemName].collapsed; + return computeInitialWidgetBarProps(container, definitions, res); +} diff --git a/packages/web/src/widgets/WidgetColumnBar.svelte b/packages/web/src/widgets/WidgetColumnBar.svelte index e969f8dd3..71630e426 100644 --- a/packages/web/src/widgets/WidgetColumnBar.svelte +++ b/packages/web/src/widgets/WidgetColumnBar.svelte @@ -3,149 +3,59 @@ import { writable } from 'svelte/store'; import _ from 'lodash'; import { getLocalStorage, setLocalStorage } from '../utility/storageCache'; + import { + computeInitialWidgetBarProps, + handleResizeWidgetBar, + toggleCollapseWidgetBar, + WidgetBarItemDefinition, + } from '../utility/widgetBarResizing'; export let hidden = false; export let storageName = null; - let definitions = {}; + let definitions: WidgetBarItemDefinition[] = []; let clientHeight; - let definitionCount = 0; // const widgetColumnBarHeight = writable(0); const widgetColumnBarComputed = writable({}); - const fromStorage = getLocalStorage(storageName); - let resizedHeights = fromStorage?.resizedHeights || {}; + let storedProps = getLocalStorage(storageName) || {}; - $: setLocalStorage(storageName, { resizedHeights }); + $: setLocalStorage(storageName, storedProps); + + $: containerProps = { + clientHeight, + titleHeight: 30, + splitterHeight: 3, + }; // setContext('widgetColumnBarHeight', widgetColumnBarHeight); - setContext('pushWidgetItemDefinition', (name, item) => { - definitions = { - ...definitions, - [name]: { - ...item, - name, - index: definitionCount, - }, - }; - definitionCount += 1; + setContext('pushWidgetItemDefinition', item => { + definitions = [...definitions, item]; }); setContext('updateWidgetItemDefinition', (name, item) => { // console.log('WidgetColumnBar updateWidgetItemDefinition', name, item); - definitions = { - ...definitions, - [name]: { ...definitions[name], ...item }, - }; - }); - setContext('widgetResizeItem', (name, newHeight) => { - resizedHeights = { - ...resizedHeights, - [name]: newHeight, - }; - recompute(definitions); + definitions = definitions.map(def => (def.name === name ? { ...def, ...item } : def)); }); setContext('widgetColumnBarComputed', widgetColumnBarComputed); + setContext('widgetResizeItem', (name, deltaY) => { + $widgetColumnBarComputed = handleResizeWidgetBar( + containerProps, + definitions, + $widgetColumnBarComputed, + name, + deltaY + ); + }); + setContext('toggleWidgetCollapse', name => { + $widgetColumnBarComputed = toggleCollapseWidgetBar(containerProps, definitions, $widgetColumnBarComputed, name); + }); // $: $widgetColumnBarHeight = clientHeight; $: recompute(definitions); - function recompute(defs: any) { - const visibleItems = _.orderBy(_.values(defs), ['index']) - .filter(x => !x.collapsed && !x.skip && x.positiveCondition) - .map(x => x.name); - const visibleItemsCount = visibleItems.length; - - const computed = {}; - - // First pass: calculate base heights - let totalFixedHeight = 0; - let totalFlexibleItems = 0; - const itemHeights = {}; - const isResized = {}; - - for (const key of visibleItems) { - const def = defs[key]; - const minHeight = def.minimalHeight || 100; - - // Check if this item has a user-resized height - if (key in resizedHeights) { - itemHeights[key] = Math.max(resizedHeights[key], minHeight); - isResized[key] = true; - totalFixedHeight += itemHeights[key]; - } else if (def.height != null) { - let height = 0; - if (_.isString(def.height) && def.height.endsWith('px')) height = parseInt(def.height.slice(0, -2)); - else if (_.isString(def.height) && def.height.endsWith('%')) - height = (clientHeight * parseFloat(def.height.slice(0, -1))) / 100; - else height = parseInt(def.height); - - height = Math.max(height, minHeight); - itemHeights[key] = height; - isResized[key] = false; - totalFixedHeight += height; - } else { - isResized[key] = false; - totalFlexibleItems++; - } - } - - // Second pass: distribute remaining space to flexible items - const availableHeightForFlexible = clientHeight - totalFixedHeight; - for (const key of visibleItems) { - if (!(key in itemHeights)) { - const def = defs[key]; - const minHeight = def.minimalHeight || 100; - let height = totalFlexibleItems > 0 ? availableHeightForFlexible / totalFlexibleItems : minHeight; - height = Math.max(height, minHeight); - itemHeights[key] = height; - } - } - - // Third pass: scale all non-resized items proportionally to fill clientHeight exactly - const totalHeight = _.sum(Object.values(itemHeights)); - const resizedKeys = Object.keys(isResized).filter(k => isResized[k]); - const nonResizedKeys = visibleItems.filter(k => !isResized[k]); - - if (totalHeight !== clientHeight && nonResizedKeys.length > 0) { - const totalResizedHeight = _.sum(resizedKeys.map(k => itemHeights[k])); - const totalNonResizedHeight = _.sum(nonResizedKeys.map(k => itemHeights[k])); - const availableForNonResized = clientHeight - totalResizedHeight; - - if (totalNonResizedHeight > 0 && availableForNonResized > 0) { - const ratio = availableForNonResized / totalNonResizedHeight; - for (const key of nonResizedKeys) { - itemHeights[key] = itemHeights[key] * ratio; - } - } - } else if (totalHeight !== clientHeight && nonResizedKeys.length === 0) { - // All items are resized, scale proportionally - const ratio = clientHeight / totalHeight; - for (const key of visibleItems) { - itemHeights[key] = itemHeights[key] * ratio; - } - } - - // Build computed result - let visibleIndex = 0; - for (const key of visibleItems) { - computed[key] = { - size: itemHeights[key], - splitterVisible: visibleItemsCount > 1 && visibleIndex < visibleItemsCount - 1, - visibleItemsCount, - }; - visibleIndex++; - } - - // Clean up resizedHeights - remove entries for items that no longer exist - resizedHeights = _.pickBy( - _.mapValues(resizedHeights, (v, k) => { - if (k in itemHeights) return v; - return undefined; - }), - v => v != null - ); - $widgetColumnBarComputed = computed; + function recompute(defs: WidgetBarItemDefinition[]) { + $widgetColumnBarComputed = computeInitialWidgetBarProps(containerProps, defs, storedProps); } onMount(() => { diff --git a/packages/web/src/widgets/WidgetColumnBarItem.svelte b/packages/web/src/widgets/WidgetColumnBarItem.svelte index ac93644a7..bf224d590 100644 --- a/packages/web/src/widgets/WidgetColumnBarItem.svelte +++ b/packages/web/src/widgets/WidgetColumnBarItem.svelte @@ -2,10 +2,19 @@ import _ from 'lodash'; import { getContext } from 'svelte'; + import type { Readable } from 'svelte/store'; import WidgetTitle from './WidgetTitle.svelte'; import splitterDrag from '../utility/splitterDrag'; - import { getLocalStorage, setLocalStorage } from '../utility/storageCache'; + import { + PushWidgetBarItemDefinitionFunction, + UpdateWidgetBarItemDefinitionFunction, + WidgetBarComputedResult, + WidgetBarComputedProps, + ToggleCollapseWidgetItemFunction, + ResizeWidgetItemFunction, + } from '../utility/widgetBarResizing'; + // import { getLocalStorage, setLocalStorage } from '../utility/storageCache'; export let title; export let skip = false; @@ -13,9 +22,9 @@ export let height = null; export let collapsed = null; - export let storageName = null; + // export let storageName = null; export let onClose = null; - export let minimalHeight = 100; + export let minimalHeight = 50; export let name; // let size = 0; @@ -25,20 +34,24 @@ // visibleItemsCount: 0, // }); - const pushWidgetItemDefinition = getContext('pushWidgetItemDefinition') as any; - const updateWidgetItemDefinition = getContext('updateWidgetItemDefinition') as any; + const pushWidgetItemDefinition = getContext('pushWidgetItemDefinition') as PushWidgetBarItemDefinitionFunction; + const updateWidgetItemDefinition = getContext('updateWidgetItemDefinition') as UpdateWidgetBarItemDefinitionFunction; // const widgetColumnBarHeight = getContext('widgetColumnBarHeight') as any; - const widgetResizeItem = getContext('widgetResizeItem') as any; - const widgetColumnBarComputed = getContext('widgetColumnBarComputed') as any; - pushWidgetItemDefinition(name, { + const widgetResizeItem = getContext('widgetResizeItem') as ResizeWidgetItemFunction; + const widgetColumnBarComputed = getContext('widgetColumnBarComputed') as Readable; + const toggleWidgetCollapse = getContext('toggleWidgetCollapse') as ToggleCollapseWidgetItemFunction; + + pushWidgetItemDefinition({ + name, collapsed, height, - skip, - positiveCondition, - minimalHeight, + skip: skip || !positiveCondition, + minimalContentHeight: minimalHeight, }); - $: updateWidgetItemDefinition(name, { collapsed: !visible, height, skip, positiveCondition }); + + + $: updateWidgetItemDefinition(name, { collapsed, height, skip: skip || !positiveCondition }); // $: setInitialSize(height, $widgetColumnBarHeight); @@ -46,39 +59,31 @@ // setLocalStorage(storageName, { relativeHeight: size / $widgetColumnBarHeight, visible }); // } - let visible = - storageName && getLocalStorage(storageName) && getLocalStorage(storageName).visible != null - ? getLocalStorage(storageName).visible - : !collapsed; - - $: computed = $widgetColumnBarComputed[name] || {}; - $: collapsible = computed.visibleItemsCount != 1 || !visible; - $: size = computed.size ?? 100; - $: splitterVisible = computed.splitterVisible; + $: computed = $widgetColumnBarComputed[name] || ({} as WidgetBarComputedProps); {#if !skip && positiveCondition} (visible = !visible) : null} + clickable={computed.clickableTitle} + on:click={computed.clickableTitle ? () => toggleWidgetCollapse(name) : null} data-testid={$$props['data-testid']} {onClose}>{title} - {#if visible} + {#if !computed.collapsed}
- {#if splitterVisible} + {#if computed.splitterVisible}
widgetResizeItem(name, size + e.detail)} + on:resizeSplitter={e => widgetResizeItem(name, e.detail)} /> {/if} {/if}