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 { writable } from 'svelte/store';
import _ from 'lodash'; import _ from 'lodash';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache'; import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import {
computeInitialWidgetBarProps,
handleResizeWidgetBar,
toggleCollapseWidgetBar,
WidgetBarItemDefinition,
} from '../utility/widgetBarResizing';
export let hidden = false; export let hidden = false;
export let storageName = null; export let storageName = null;
let definitions = {}; let definitions: WidgetBarItemDefinition[] = [];
let clientHeight; let clientHeight;
let definitionCount = 0;
// const widgetColumnBarHeight = writable(0); // const widgetColumnBarHeight = writable(0);
const widgetColumnBarComputed = writable({}); const widgetColumnBarComputed = writable({});
const fromStorage = getLocalStorage(storageName); let storedProps = getLocalStorage(storageName) || {};
let resizedHeights = fromStorage?.resizedHeights || {};
$: setLocalStorage(storageName, { resizedHeights }); $: setLocalStorage(storageName, storedProps);
$: containerProps = {
clientHeight,
titleHeight: 30,
splitterHeight: 3,
};
// setContext('widgetColumnBarHeight', widgetColumnBarHeight); // setContext('widgetColumnBarHeight', widgetColumnBarHeight);
setContext('pushWidgetItemDefinition', (name, item) => { setContext('pushWidgetItemDefinition', item => {
definitions = { definitions = [...definitions, item];
...definitions,
[name]: {
...item,
name,
index: definitionCount,
},
};
definitionCount += 1;
}); });
setContext('updateWidgetItemDefinition', (name, item) => { setContext('updateWidgetItemDefinition', (name, item) => {
// console.log('WidgetColumnBar updateWidgetItemDefinition', name, item); // console.log('WidgetColumnBar updateWidgetItemDefinition', name, item);
definitions = { definitions = definitions.map(def => (def.name === name ? { ...def, ...item } : def));
...definitions,
[name]: { ...definitions[name], ...item },
};
});
setContext('widgetResizeItem', (name, newHeight) => {
resizedHeights = {
...resizedHeights,
[name]: newHeight,
};
recompute(definitions);
}); });
setContext('widgetColumnBarComputed', widgetColumnBarComputed); 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; // $: $widgetColumnBarHeight = clientHeight;
$: recompute(definitions); $: recompute(definitions);
function recompute(defs: any) { function recompute(defs: WidgetBarItemDefinition[]) {
const visibleItems = _.orderBy(_.values(defs), ['index']) $widgetColumnBarComputed = computeInitialWidgetBarProps(containerProps, defs, storedProps);
.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;
} }
onMount(() => { onMount(() => {

View File

@@ -2,10 +2,19 @@
import _ from 'lodash'; import _ from 'lodash';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import WidgetTitle from './WidgetTitle.svelte'; import WidgetTitle from './WidgetTitle.svelte';
import splitterDrag from '../utility/splitterDrag'; 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 title;
export let skip = false; export let skip = false;
@@ -13,9 +22,9 @@
export let height = null; export let height = null;
export let collapsed = null; export let collapsed = null;
export let storageName = null; // export let storageName = null;
export let onClose = null; export let onClose = null;
export let minimalHeight = 100; export let minimalHeight = 50;
export let name; export let name;
// let size = 0; // let size = 0;
@@ -25,20 +34,24 @@
// visibleItemsCount: 0, // visibleItemsCount: 0,
// }); // });
const pushWidgetItemDefinition = getContext('pushWidgetItemDefinition') as any; const pushWidgetItemDefinition = getContext('pushWidgetItemDefinition') as PushWidgetBarItemDefinitionFunction;
const updateWidgetItemDefinition = getContext('updateWidgetItemDefinition') as any; const updateWidgetItemDefinition = getContext('updateWidgetItemDefinition') as UpdateWidgetBarItemDefinitionFunction;
// const widgetColumnBarHeight = getContext('widgetColumnBarHeight') as any; // const widgetColumnBarHeight = getContext('widgetColumnBarHeight') as any;
const widgetResizeItem = getContext('widgetResizeItem') as any; const widgetResizeItem = getContext('widgetResizeItem') as ResizeWidgetItemFunction;
const widgetColumnBarComputed = getContext('widgetColumnBarComputed') as any; const widgetColumnBarComputed = getContext('widgetColumnBarComputed') as Readable<WidgetBarComputedResult>;
pushWidgetItemDefinition(name, { const toggleWidgetCollapse = getContext('toggleWidgetCollapse') as ToggleCollapseWidgetItemFunction;
pushWidgetItemDefinition({
name,
collapsed, collapsed,
height, height,
skip, skip: skip || !positiveCondition,
positiveCondition, minimalContentHeight: minimalHeight,
minimalHeight,
}); });
$: updateWidgetItemDefinition(name, { collapsed: !visible, height, skip, positiveCondition });
$: updateWidgetItemDefinition(name, { collapsed, height, skip: skip || !positiveCondition });
// $: setInitialSize(height, $widgetColumnBarHeight); // $: setInitialSize(height, $widgetColumnBarHeight);
@@ -46,39 +59,31 @@
// setLocalStorage(storageName, { relativeHeight: size / $widgetColumnBarHeight, visible }); // setLocalStorage(storageName, { relativeHeight: size / $widgetColumnBarHeight, visible });
// } // }
let visible = $: computed = $widgetColumnBarComputed[name] || ({} as WidgetBarComputedProps);
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;
</script> </script>
{#if !skip && positiveCondition} {#if !skip && positiveCondition}
<WidgetTitle <WidgetTitle
clickable={collapsible} clickable={computed.clickableTitle}
on:click={collapsible ? () => (visible = !visible) : null} on:click={computed.clickableTitle ? () => toggleWidgetCollapse(name) : null}
data-testid={$$props['data-testid']} data-testid={$$props['data-testid']}
{onClose}>{title}</WidgetTitle {onClose}>{title}</WidgetTitle
> >
{#if visible} {#if !computed.collapsed}
<div <div
class="wrapper" 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} data-testid={$$props['data-testid'] ? `${$$props['data-testid']}_content` : undefined}
> >
<slot /> <slot />
</div> </div>
{#if splitterVisible} {#if computed.splitterVisible}
<div <div
class="vertical-split-handle" class="vertical-split-handle"
use:splitterDrag={'clientY'} use:splitterDrag={'clientY'}
on:resizeSplitter={e => widgetResizeItem(name, size + e.detail)} on:resizeSplitter={e => widgetResizeItem(name, e.detail)}
/> />
{/if} {/if}
{/if} {/if}