SYNC: widgetbar refactor

This commit is contained in:
SPRINX0\prochazka
2025-12-11 12:29:08 +01:00
committed by Diflow
parent 3d2ad1cb9b
commit efefec3c20
3 changed files with 287 additions and 149 deletions

View File

@@ -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<WidgetBarItemDefinition>) => 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);
}

View File

@@ -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(() => {

View File

@@ -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<WidgetBarComputedResult>;
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);
</script>
{#if !skip && positiveCondition}
<WidgetTitle
clickable={collapsible}
on:click={collapsible ? () => (visible = !visible) : null}
clickable={computed.clickableTitle}
on:click={computed.clickableTitle ? () => toggleWidgetCollapse(name) : null}
data-testid={$$props['data-testid']}
{onClose}>{title}</WidgetTitle
>
{#if visible}
{#if !computed.collapsed}
<div
class="wrapper"
style={splitterVisible ? `height:${size}px` : 'flex: 1 1 0'}
style={computed.splitterVisible ? `height:${computed.contentHeight}px` : 'flex: 1 1 0'}
data-testid={$$props['data-testid'] ? `${$$props['data-testid']}_content` : undefined}
>
<slot />
</div>
{#if splitterVisible}
{#if computed.splitterVisible}
<div
class="vertical-split-handle"
use:splitterDrag={'clientY'}
on:resizeSplitter={e => widgetResizeItem(name, size + e.detail)}
on:resizeSplitter={e => widgetResizeItem(name, e.detail)}
/>
{/if}
{/if}