diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/business/balance-space.spec.ts b/src/common/components/mock-components/front-rich-components/tabsbar/business/balance-space.spec.ts new file mode 100644 index 00000000..a228db36 --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/tabsbar/business/balance-space.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { balanceSpacePerItem } from './balance-space'; + +const _sum = (resultado: number[]) => + resultado.reduce((acc, current) => acc + current, 0); + +describe('balanceSpacePerItem tests', () => { + it('should return an array which sums 150 when apply [10, 20, 30, 40, 50]', () => { + // Arrange + const theArray = [10, 20, 30, 40, 50]; + const availableWidth = 150; + + // Act + const result = balanceSpacePerItem(theArray, availableWidth); + const totalSum = _sum(result); + + // Assert + expect(totalSum).toBeGreaterThan(0); + expect(totalSum).toBeLessThanOrEqual(availableWidth); + }); + + it('should return an array which sums equal or less than 100 when apply [10, 20, 30, 40, 50]', () => { + // Arrange + const theArray = [10, 20, 30, 40, 50]; + const availableWidth = 100; + + // Act + const result = balanceSpacePerItem(theArray, availableWidth); + const totalSum = _sum(result); + + // Assert + expect(totalSum).toBeGreaterThan(0); + expect(totalSum).toBeLessThanOrEqual(availableWidth); + }); + + it('should return an array which sums less or equal than 150 when apply [10, 20, 31, 41, 50]', () => { + // Arrange + const theArray = [10, 20, 31, 41, 50]; + const availableWidth = 150; + + // Act + const result = balanceSpacePerItem(theArray, availableWidth); + const totalSum = _sum(result); + + // Assert + expect(totalSum).toBeGreaterThan(0); + expect(totalSum).toBeLessThanOrEqual(availableWidth); + }); + + it('should return an array which sums 10 when apply [10]', () => { + // Arrange + const theArray = [100]; + const availableWidth = 10; + + // Act + const result = balanceSpacePerItem(theArray, availableWidth); + const totalSum = _sum(result); + + // Assert + expect(totalSum).toBeGreaterThan(0); + expect(totalSum).toBeLessThanOrEqual(availableWidth); + }); + + it('should return an array which sums 18 when apply [10, 10]', () => { + // Arrange + const theArray = [10, 10]; + const availableWidth = 18; + + // Act + const result = balanceSpacePerItem(theArray, availableWidth); + const totalSum = _sum(result); + + // Assert + expect(totalSum).toBeGreaterThan(0); + expect(totalSum).toBeLessThanOrEqual(availableWidth); + }); +}); diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/business/balance-space.ts b/src/common/components/mock-components/front-rich-components/tabsbar/business/balance-space.ts new file mode 100644 index 00000000..8e683166 --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/tabsbar/business/balance-space.ts @@ -0,0 +1,75 @@ +/** + * This calc is made "layer by layer", distributing a larger chunk of width in each iteration + * @param {Array} itemList - List of spaces to balance (Must be provided in ascendent order to work) + * @param {Number} availableSpace - The amount of space to be distributed + */ +export const balanceSpacePerItem = ( + itemList: number[], + availableSpace: number +) => { + const totalSpaceUsed = _spacesFactory(); + const maxItemSize = _spacesFactory(); + + return itemList.reduce((newList: number[], current, index, arr) => { + // Check if the array provided is properly ordered + if (index > 0) _checkListOrder(arr[index - 1], current); + + const lastItemSize: number = index > 0 ? newList[index - 1] : 0; + + // A) Once the maximum possible size of the item is reached, apply this size directly. + if (maxItemSize.value) { + totalSpaceUsed.add(maxItemSize.value); + return [...newList, lastItemSize]; + } + + /** Precalculate "existingSum + spaceSum" taking into account + * all next items supposing all they use current size */ + const timesToApply = arr.length - index; + const virtualTotalsSum = totalSpaceUsed.value + current * timesToApply; + + /** B) First "Bigger" tab behaviour: If the virtual-sum of next items using this size + * doesn't fit within available space, calc maxItemSize */ + if (virtualTotalsSum >= availableSpace) { + const remainder = + availableSpace - (totalSpaceUsed.value + lastItemSize * timesToApply); + const remainderPortionPerItem = Math.floor(remainder / timesToApply); + maxItemSize.set(lastItemSize + remainderPortionPerItem); + + totalSpaceUsed.add(maxItemSize.value); + + return [...newList, maxItemSize.value]; + } + + // C) "Normal" behaviour: Apply proposed new size to current + totalSpaceUsed.add(current); + return [...newList, current]; + }, []); +}; + +/* Balance helper functions: */ + +function _checkListOrder(prev: number, current: number) { + if (prev > current) { + throw new Error( + 'Disordered list. Please provide an ascendent ordered list as param *itemlist*' + ); + } +} + +function _spacesFactory() { + let _size = 0; + //Assure we are setting natural num w/o decimals + const _adjustNum = (num: number) => { + if (typeof num !== 'number') throw new Error('Number must be provided'); + return Math.max(0, Math.floor(num)); + }; + const add = (qty: number) => (_size += _adjustNum(qty)); + const set = (qty: number) => (_size = _adjustNum(qty)); + return { + get value() { + return _size; + }, + add, + set, + }; +} diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/business/calc-text-width.ts b/src/common/components/mock-components/front-rich-components/tabsbar/business/calc-text-width.ts new file mode 100644 index 00000000..843bd15e --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/tabsbar/business/calc-text-width.ts @@ -0,0 +1,50 @@ +import { Layer } from 'konva/lib/Layer'; + +/** + * Virtually calculates the width that a text will occupy, by using a canvas. + * If a Konva Layer is provided, it will reuse the already existing canvas. + * Otherwise, it will create a canvas within the document, on the fly, to perform the measurement. + * Finaly, as a safety net, a very generic calculation is provided in case the other options are not available. + */ +export const calcTextWidth = ( + inputText: string, + fontSize: number, + fontfamily: string, + konvaLayer?: Layer +) => { + if (konvaLayer) + return _getTextWidthByKonvaMethod( + konvaLayer, + inputText, + fontSize, + fontfamily + ); + + return _getTextCreatingNewCanvas(inputText, fontSize, fontfamily); +}; + +const _getTextWidthByKonvaMethod = ( + konvaLayer: Layer, + text: string, + fontSize: number, + fontfamily: string +) => { + const context = konvaLayer.getContext(); + context.font = `${fontSize}px ${fontfamily}`; + return context.measureText(text).width; +}; + +const _getTextCreatingNewCanvas = ( + text: string, + fontSize: number, + fontfamily: string +) => { + let canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context) { + context.font = `${fontSize}px ${fontfamily}`; + return context.measureText(text).width; + } + const charAverageWidth = fontSize * 0.7; + return text.length * charAverageWidth + charAverageWidth * 0.8; +}; diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/business/tabsbar.business.spec.ts b/src/common/components/mock-components/front-rich-components/tabsbar/business/tabsbar.business.spec.ts new file mode 100644 index 00000000..5e6b414b --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/tabsbar/business/tabsbar.business.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { adjustTabWidths } from './tabsbar.business'; + +const _sum = (resultado: number[]) => + resultado.reduce((acc, current) => acc + current, 0); + +describe('tabsbar.business tests', () => { + it('should return a new array of numbers, which sum is less than or equal to totalWidth', () => { + // Arrange + const tabs = [ + 'Text', + 'Normal text for tab', + 'Extra large text for a tab', + 'Really really large text for a tab', + 'xs', + ]; + const containerWidth = 1000; + const minTabWidth = 100; + const tabsGap = 10; + + // Act + const result = adjustTabWidths({ + tabs, + containerWidth, + minTabWidth, + tabXPadding: 20, + tabsGap, + font: { fontSize: 14, fontFamily: 'Arial' }, + }); + + console.log({ tabs }, { containerWidth }, { minTabWidth }); + console.log({ result }); + + const totalSum = _sum(result.widthList) + (tabs.length - 1) * tabsGap; + console.log('totalSum: ', totalSum); + + // Assert + expect(result.widthList[0]).not.toBe(0); + expect(result.widthList.length).toBe(tabs.length); + expect(totalSum).toBeLessThanOrEqual(containerWidth); + }); +}); diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/business/tabsbar.business.ts b/src/common/components/mock-components/front-rich-components/tabsbar/business/tabsbar.business.ts new file mode 100644 index 00000000..ce08f8bb --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/tabsbar/business/tabsbar.business.ts @@ -0,0 +1,87 @@ +import { Layer } from 'konva/lib/Layer'; +import { balanceSpacePerItem } from './balance-space'; +import { calcTextWidth } from './calc-text-width'; + +export const adjustTabWidths = (args: { + tabs: string[]; + containerWidth: number; + minTabWidth: number; + tabXPadding: number; + tabsGap: number; + font: { + fontSize: number; + fontFamily: string; + }; + konvaLayer?: Layer; +}) => { + const { + tabs, + containerWidth, + minTabWidth, + tabXPadding, + tabsGap, + font, + konvaLayer, + } = args; + const totalInnerXPadding = tabXPadding * 2; + const totalMinTabSpace = minTabWidth + totalInnerXPadding; + const containerWidthWithoutTabGaps = + containerWidth - (tabs.length - 1) * tabsGap; + + //Create info List with originalPositions and desired width + interface OriginalTabInfo { + originalTabPosition: number; + desiredWidth: number; + } + const arrangeTabsInfo = tabs.reduce( + (acc: OriginalTabInfo[], tab, index): OriginalTabInfo[] => { + const tabFullTextWidth = + calcTextWidth(tab, font.fontSize, font.fontFamily, konvaLayer) + + totalInnerXPadding; + const desiredWidth = Math.max(totalMinTabSpace, tabFullTextWidth); + return [ + ...acc, + { + originalTabPosition: index, + desiredWidth, + }, + ]; + }, + [] + ); + + // This order is necessary to build layer by layer the new sizes + const ascendentTabList = arrangeTabsInfo.sort( + (a, b) => a.desiredWidth - b.desiredWidth + ); + + const onlyWidthList = ascendentTabList.map(tab => tab.desiredWidth); + // Apply adjustments + const adjustedSizeList = balanceSpacePerItem( + onlyWidthList, + containerWidthWithoutTabGaps + ); + + // Reassemble new data with the original order + const reassembledData = ascendentTabList.reduce( + (accList: number[], current, index) => { + const newList = [...accList]; + newList[current.originalTabPosition] = adjustedSizeList[index]; + return newList; + }, + [] + ); + + // Calc item offset position (mixed with external variable to avoid adding to reducer() extra complexity) + let sumOfXposition = 0; + const relativeTabPosition = reassembledData.reduce( + (acc: number[], currentTab, index) => { + const currentElementXPos = index ? sumOfXposition : 0; + sumOfXposition += currentTab + tabsGap; + return [...acc, currentElementXPos]; + }, + [] + ); + + return { widthList: reassembledData, relativeTabPosition }; +}; diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/tab-list.hook.ts b/src/common/components/mock-components/front-rich-components/tabsbar/tab-list.hook.ts new file mode 100644 index 00000000..e115f4c2 --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/tabsbar/tab-list.hook.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { adjustTabWidths } from './business/tabsbar.business'; +import { + extractCSVHeaders, + splitCSVContentIntoRows, +} from '@/common/utils/active-element-selector.utils'; +import { useCanvasContext } from '@/core/providers'; + +interface TabListConfig { + text: string; + containerWidth: number; + minTabWidth: number; + tabXPadding: number; + tabsGap: number; + font: { + fontSize: number; + fontFamily: string; + }; +} + +export const useTabList = (tabsConfig: TabListConfig) => { + const { text, containerWidth, minTabWidth, tabXPadding, tabsGap, font } = + tabsConfig; + + const [tabWidthList, setTabWidthList] = useState<{ + widthList: number[]; + relativeTabPosition: number[]; + }>({ widthList: [], relativeTabPosition: [] }); + + const tabLabels = _extractTabLabelTexts(text); + + const konvaLayer = useCanvasContext().stageRef.current?.getLayers()[0]; + + useEffect(() => { + setTabWidthList( + adjustTabWidths({ + tabs: tabLabels, + containerWidth, + minTabWidth, + tabXPadding, + tabsGap, + font: { + fontSize: font.fontSize, + fontFamily: font.fontFamily, + }, + konvaLayer, + }) + ); + }, [text, containerWidth]); + + //Return an unique array with all the info required by each tab + return tabLabels.map((tab, index) => ({ + tab, + width: tabWidthList.widthList[index], + xPos: tabWidthList.relativeTabPosition[index], + })); +}; + +// Split text to tab labels List +function _extractTabLabelTexts(text: string) { + const csvData = splitCSVContentIntoRows(text); + const headers = extractCSVHeaders(csvData[0]); + return headers.map(header => header.text); +} diff --git a/src/common/components/mock-components/front-rich-components/tabsbar/tabsbar-shape.tsx b/src/common/components/mock-components/front-rich-components/tabsbar/tabsbar-shape.tsx index 7e872a5a..d76cb6cf 100644 --- a/src/common/components/mock-components/front-rich-components/tabsbar/tabsbar-shape.tsx +++ b/src/common/components/mock-components/front-rich-components/tabsbar/tabsbar-shape.tsx @@ -3,11 +3,8 @@ import { Group, Rect, Text } from 'react-konva'; import { ShapeSizeRestrictions, ShapeType } from '@/core/model'; import { fitSizeToShapeSizeRestrictions } from '@/common/utils/shapes/shape-restrictions'; import { ShapeProps } from '../../shape.model'; -import { - extractCSVHeaders, - splitCSVContentIntoRows, -} from '@/common/utils/active-element-selector.utils'; import { useGroupShapeProps } from '../../mock-components.utils'; +import { useTabList } from './tab-list.hook'; const tabsBarShapeSizeRestrictions: ShapeSizeRestrictions = { minWidth: 450, @@ -15,7 +12,7 @@ const tabsBarShapeSizeRestrictions: ShapeSizeRestrictions = { maxWidth: -1, maxHeight: -1, defaultWidth: 450, - defaultHeight: 150, + defaultHeight: 180, }; export const getTabsBarShapeSizeRestrictions = (): ShapeSizeRestrictions => @@ -42,16 +39,21 @@ export const TabsBarShape = forwardRef((props, ref) => { ); const { width: restrictedWidth, height: restrictedHeight } = restrictedSize; - const csvData = splitCSVContentIntoRows(text); - const headers = extractCSVHeaders(csvData[0]); - const tabLabels = headers.map(header => header.text); - - // Calculate tab dimensions and margin - const tabWidth = 106; // Width of each tab - const tabHeight = 30; // Tab height - const tabMargin = 10; // Horizontal margin between tabs + // Tab dimensions and margin + const tabHeight = 30; + const tabsGap = 10; + const tabXPadding = 20; + const tabFont = { fontSize: 14, fontFamily: 'Arial, sans-serif' }; const bodyHeight = restrictedHeight - tabHeight - 10; // Height of the tabs bar body - const totalTabsWidth = tabLabels.length * (tabWidth + tabMargin) + tabWidth; // Total width required plus one additional tab + + const tabList = useTabList({ + text, + containerWidth: restrictedWidth - tabsGap * 2, //left and right tabList margin + minTabWidth: 40, // Min-width of each tab, without xPadding + tabXPadding, + tabsGap, + font: tabFont, + }); const activeTab = otherProps?.activeElement ?? 0; @@ -68,36 +70,41 @@ export const TabsBarShape = forwardRef((props, ref) => { {/* Map through headerRow to create tabs */} - {tabLabels.map((header, index) => ( - - - - - ))} + {tabList.map(({ tab, width, xPos }, index) => { + return ( + + {/* || 0 Workaround to avoid thumbpage NaN issue with konva */} + + + + ); + })} ); });