diff --git a/public/rich-components/file-tree.svg b/public/rich-components/file-tree.svg new file mode 100644 index 00000000..f51ce1a4 --- /dev/null +++ b/public/rich-components/file-tree.svg @@ -0,0 +1,28 @@ + + + + + + + + + Folder 1 + + + + + + Subfolder + + + + + + File + + + + + + Folder 2 + diff --git a/src/common/components/mock-components/front-rich-components/file-tree/file-tree-resize.hook.ts b/src/common/components/mock-components/front-rich-components/file-tree/file-tree-resize.hook.ts new file mode 100644 index 00000000..fef5ba58 --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/file-tree/file-tree-resize.hook.ts @@ -0,0 +1,132 @@ +import { ElementSize, Size } from '@/core/model'; +import { useCanvasContext } from '@/core/providers'; +import { useEffect, useRef } from 'react'; +import { FileTreeItem, FileTreeSizeValues } from './file-tree.model'; + +// Hook to resize edition text area based on content +const useFileTreeResizeOnContentChange = ( + id: string, + coords: { x: number; y: number }, + text: string, + currentSize: Size, + calculatedSize: Size, + minHeight: number +) => { + const previousText = useRef(text); + const { updateShapeSizeAndPosition } = useCanvasContext(); + + useEffect(() => { + const textChanged = previousText.current !== text; + + const finalHeight = Math.max(calculatedSize.height, minHeight); + const finalSize = { ...calculatedSize, height: finalHeight }; + + const sizeChanged = + finalHeight !== currentSize.height || + calculatedSize.width !== currentSize.width; + + if (textChanged && sizeChanged) { + previousText.current = text; + updateShapeSizeAndPosition(id, coords, finalSize, false); + } else if (sizeChanged) { + // If only the size has changed, also resize + updateShapeSizeAndPosition(id, coords, finalSize, false); + } + }, [ + text, + calculatedSize.height, + calculatedSize.width, + currentSize.height, + currentSize.width, + id, + coords.x, + coords.y, + updateShapeSizeAndPosition, + ]); +}; + +// Hook to force width change when ElementSize changes (XS ↔ S) +// This ensures that when dropping a component and changing from S to XS (or vice versa), +// the component doesn't maintain the previous width but forces the correct one: +// - XS: 150px width +// - S: 230px width + +const useFileTreeResizeOnSizeChange = ( + id: string, + coords: { x: number; y: number }, + currentSize: Size, + treeItems: FileTreeItem[], + sizeValues: FileTreeSizeValues, + size?: ElementSize +) => { + const previousSize = useRef(size); + const { updateShapeSizeAndPosition } = useCanvasContext(); + + useEffect(() => { + // Only update if the size has changed + if (previousSize.current !== size) { + previousSize.current = size; + + const newWidth = size === 'XS' ? 150 : 230; + + const minContentHeight = + treeItems && sizeValues + ? treeItems.length * sizeValues.elementHeight + + sizeValues.paddingY * 2 + : currentSize.height; + + if ( + currentSize.width !== newWidth || + currentSize.height !== minContentHeight + ) { + updateShapeSizeAndPosition( + id, + coords, + { width: newWidth, height: minContentHeight }, + false + ); + } + } + }, [ + size, + currentSize.width, + currentSize.height, + id, + coords.x, + coords.y, + updateShapeSizeAndPosition, + treeItems, + sizeValues, + ]); +}; + +export const useFileTreeResize = ( + id: string, + coords: { x: number; y: number }, + text: string, + currentSize: Size, + calculatedSize: Size, + minHeight: number, + treeItems: FileTreeItem[], + sizeValues: FileTreeSizeValues, + size?: ElementSize +) => { + useFileTreeResizeOnContentChange( + id, + coords, + text, + currentSize, + calculatedSize, + minHeight + ); + + useFileTreeResizeOnSizeChange( + id, + coords, + currentSize, + + treeItems, + sizeValues, + size + ); +}; diff --git a/src/common/components/mock-components/front-rich-components/file-tree/file-tree.business.spec.ts b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.business.spec.ts new file mode 100644 index 00000000..b0b6aeca --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.business.spec.ts @@ -0,0 +1,230 @@ +import { parseFileTreeText } from './file-tree.business'; + +describe('parseFileTreeText', () => { + describe('Basic functionality', () => { + it.each([ + ['+ Documents', 'folder', 'Documents'], + ['- Downloads', 'subfolder', 'Downloads'], + ['* README.md', 'file', 'README.md'], + ['Projects', 'folder', 'Projects'], + ])( + 'should parse %s as %s with text "%s"', + (text, expectedType, expectedText) => { + // Arrange + + // Act + const result = parseFileTreeText(text); + + // Assert + expect(result).toEqual([ + { + type: expectedType, + text: expectedText, + level: 0, + }, + ]); + } + ); + }); + + describe('Indentation levels', () => { + it.each<{ description: string; text: string; expectedLevel: number }>([ + { + description: 'no spaces create level 0', + text: '+ Root', + expectedLevel: 0, + }, + { + description: '3 spaces create level 1', + text: ' + Subfolder', + expectedLevel: 1, + }, + { + description: '6 spaces create level 2', + text: ' * File', + expectedLevel: 2, + }, + { + description: '9 spaces create level 3', + text: ' + Deep folder', + expectedLevel: 3, + }, + ])('$description', ({ text, expectedLevel }) => { + // Arrange + + // Act + const result = parseFileTreeText(text); + + // Assert + expect(result[0].level).toBe(expectedLevel); + }); + + it('should handle indentation with non-standard spacing', () => { + // Arrange + const text = ` + Two spaces (level 0) + + Four spaces (level 1) + + Five spaces (level 1) + + Seven spaces (level 2)`; + + // Act + const result = parseFileTreeText(text); + + // Assert + expect(result[0].level).toBe(0); // 2/3 = 0 + expect(result[1].level).toBe(1); // 4/3 = 1 + expect(result[2].level).toBe(1); // 5/3 = 1 + expect(result[3].level).toBe(2); // 7/3 = 2 + }); + + it('should handle complex nested structure', () => { + // Arrange + const text = `+ Root + - Subfolder 1 + * File 1 + + Subfolder 2 + - Deep subfolder + * Deep file + + Deep folder + - Deep subfolder 2 + * Deep file 2`; + + // Act + const result = parseFileTreeText(text); + + // Assert + expect(result).toEqual([ + { type: 'folder', text: 'Root', level: 0 }, + { type: 'subfolder', text: 'Subfolder 1', level: 1 }, + { type: 'file', text: 'File 1', level: 2 }, + { type: 'folder', text: 'Subfolder 2', level: 1 }, + { type: 'subfolder', text: 'Deep subfolder', level: 1 }, + { type: 'file', text: 'Deep file', level: 2 }, + { type: 'folder', text: 'Deep folder', level: 3 }, + { type: 'subfolder', text: 'Deep subfolder 2', level: 5 }, + { type: 'file', text: 'Deep file 2', level: 6 }, + ]); + }); + }); + + describe('Corner cases', () => { + it.each<{ description: string; input: string; expected: any[] }>([ + { + description: 'return empty array for empty string', + input: '', + expected: [], + }, + { + description: + 'filter out lines with only newlines between valid content', + input: ` + ++ Folder + +* File + +`, + expected: [ + { type: 'folder', text: 'Folder', level: 0 }, + { type: 'file', text: 'File', level: 0 }, + ], + }, + { + description: 'return empty array for text with only newlines', + input: '\n\n\n', + expected: [], + }, + ])('should $description', ({ input, expected }) => { + // Arrange + + // Act + const result = parseFileTreeText(input); + + // Assert + expect(result).toEqual(expected); + }); + }); + + describe('Edge cases with symbols', () => { + it.each<{ text: string; expected: any[]; description: string }>([ + { + description: + 'ignore extra spaces after the symbol and keep correct type', + text: `+ Documents +- Downloads +* README.md`, + expected: [ + { type: 'folder', text: 'Documents', level: 0 }, + { type: 'subfolder', text: 'Downloads', level: 0 }, + { type: 'file', text: 'README.md', level: 0 }, + ], + }, + { + description: 'handle symbols without space after as plain text', + text: `+ +- +*`, + expected: [ + { type: 'folder', text: '+', level: 0 }, + { type: 'folder', text: '-', level: 0 }, + { type: 'folder', text: '*', level: 0 }, + ], + }, + { + description: 'trim leading/trailing whitespace in text', + text: `+ Documents +- Downloads +* README.md `, + expected: [ + { type: 'folder', text: 'Documents', level: 0 }, + { type: 'subfolder', text: 'Downloads', level: 0 }, + { type: 'file', text: 'README.md', level: 0 }, + ], + }, + { + description: 'recognize symbols with space but no text', + text: `+ +- +* `, + expected: [ + { type: 'folder', text: '', level: 0 }, + { type: 'subfolder', text: '', level: 0 }, + { type: 'file', text: '', level: 0 }, + ], + }, + { + description: + 'handle lines starting with symbols but not followed by space, as folder and plain text', + text: `+Documents +-Downloads +*README.md`, + expected: [ + { type: 'folder', text: '+Documents', level: 0 }, + { type: 'folder', text: '-Downloads', level: 0 }, + { type: 'folder', text: '*README.md', level: 0 }, + ], + }, + ])('should $description', ({ text, expected }) => { + // Arrange + + // Act + const result = parseFileTreeText(text); + + // Assert + expect(result).toEqual(expected); + }); + }); + + describe('Large indentation values', () => { + it('should handle 27 spaces creating level 9 indentation', () => { + // Arrange + const spaces = ' '; // 27 spaces + + // Act + const text = `${spaces}+ Deep folder`; + const result = parseFileTreeText(text); + + // Assert + expect(result[0].level).toBe(9); + }); + }); +}); diff --git a/src/common/components/mock-components/front-rich-components/file-tree/file-tree.business.ts b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.business.ts new file mode 100644 index 00000000..5bddbaec --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.business.ts @@ -0,0 +1,134 @@ +import { fitSizeToShapeSizeRestrictions } from '@/common/utils/shapes'; +import { ElementSize, ShapeSizeRestrictions, Size } from '@/core/model'; +import { FONT_SIZE_VALUES } from '../../front-components/shape.const'; +import { + FileTreeDynamicSizeParams, + FileTreeItem, + FileTreeSizeValues, +} from './file-tree.model'; + +export const getFileTreeSizeValues = ( + size?: ElementSize +): FileTreeSizeValues => { + switch (size) { + case 'XS': + return { + fontSize: 12, + iconDimension: 25, + elementHeight: 30, + paddingX: 25, + paddingY: 15, + extraTextTopPadding: 9, + iconTextSpacing: 8, + indentationStep: 15, + }; + case 'S': + return { + fontSize: FONT_SIZE_VALUES.NORMALTEXT, + iconDimension: 50, + elementHeight: 60, + paddingX: 30, + paddingY: 20, + extraTextTopPadding: 20, + iconTextSpacing: 10, + indentationStep: 27, + }; + default: + return { + fontSize: FONT_SIZE_VALUES.NORMALTEXT, + iconDimension: 50, + elementHeight: 60, + paddingX: 30, + paddingY: 20, + extraTextTopPadding: 20, + iconTextSpacing: 10, + indentationStep: 25, + }; + } +}; + +export const parseFileTreeText = (text: string): FileTreeItem[] => { + return text + .split('\n') + .map(line => { + // First detect indentation + const indentMatch = line.match(/^(\s*)/); + const level = indentMatch ? Math.floor(indentMatch[1].length / 3) : 0; + const trimmedStart = line.trimStart(); + + if (trimmedStart === '') return null; + + // Detect symbol + if (trimmedStart.startsWith('+ ')) { + return { + type: 'folder', + text: trimmedStart.substring(2).trim(), + level: level, + }; + } + + if (trimmedStart.startsWith('- ')) { + return { + type: 'subfolder', + text: trimmedStart.substring(2).trim(), + level: level, + }; + } + + if (trimmedStart.startsWith('* ')) { + return { + type: 'file', + text: trimmedStart.substring(2).trim(), + level: level, + }; + } + + // No symbol: will be treated as a folder + const trimmed = line.trim(); + return { + type: 'folder', + text: trimmed, + level: level, + }; + }) + .filter((item): item is FileTreeItem => item !== null); +}; + +export const calculateFileTreeDynamicSize = ( + treeItems: FileTreeItem[], + params: FileTreeDynamicSizeParams +): Size => { + const { + width, + height, + elementHeight, + paddingY, + paddingX, + iconDimension, + indentationStep, + baseRestrictions, + } = params; + + const maxIconX = Math.max( + ...treeItems.map(item => paddingX + item.level * indentationStep) + ); + const requiredWidth = maxIconX + iconDimension + paddingX; + + // Calculate minimum height required based on content + const minContentHeight = treeItems.length * elementHeight + paddingY * 2; + + // Create dynamic constraints for content-based sizing + const dynamicRestrictions: ShapeSizeRestrictions = { + ...baseRestrictions, + minWidth: Math.max(baseRestrictions.minWidth, requiredWidth), + defaultHeight: minContentHeight, + }; + + const finalHeight = Math.max(height, minContentHeight); + + return fitSizeToShapeSizeRestrictions( + dynamicRestrictions, + width, + finalHeight + ); +}; diff --git a/src/common/components/mock-components/front-rich-components/file-tree/file-tree.model.ts b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.model.ts new file mode 100644 index 00000000..7415a168 --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.model.ts @@ -0,0 +1,31 @@ +import { ShapeSizeRestrictions } from '@/core/model'; + +export interface FileTreeSizeValues { + fontSize: number; + iconDimension: number; + elementHeight: number; + paddingX: number; + paddingY: number; + extraTextTopPadding: number; + iconTextSpacing: number; + indentationStep: number; +} + +// Symbol -> + Folder - Subfolder * File +// Level -> Level 0: no indentation in Folder / Level 1: 1 indentation (3 spaces) in Subfolder / Level 2: 2 indentations (6 spaces) in File +export interface FileTreeItem { + type: 'folder' | 'subfolder' | 'file'; + text: string; + level: number; +} + +export interface FileTreeDynamicSizeParams { + width: number; + height: number; + elementHeight: number; + paddingY: number; + paddingX: number; + iconDimension: number; + indentationStep: number; + baseRestrictions: ShapeSizeRestrictions; +} diff --git a/src/common/components/mock-components/front-rich-components/file-tree/file-tree.tsx b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.tsx new file mode 100644 index 00000000..bb5c064d --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/file-tree/file-tree.tsx @@ -0,0 +1,189 @@ +import { ShapeSizeRestrictions, ShapeType } from '@/core/model'; +import { forwardRef, useEffect, useMemo, useState } from 'react'; +import { Group, Image, Rect, Text } from 'react-konva'; +import { ShapeProps } from '../../shape.model'; +import { useGroupShapeProps } from '../../mock-components.utils'; +import { loadSvgWithFill } from '@/common/utils/svg.utils'; +import { useShapeProps } from '@/common/components/shapes/use-shape-props.hook'; +import { BASIC_SHAPE } from '../../front-components/shape.const'; +import { + calculateFileTreeDynamicSize, + getFileTreeSizeValues, + parseFileTreeText, +} from './file-tree.business'; +import { useFileTreeResize } from './file-tree-resize.hook'; +import { FileTreeItem } from './file-tree.model'; + +const fileTreeShapeRestrictions: ShapeSizeRestrictions = { + minWidth: 150, + minHeight: 50, + maxWidth: -1, + maxHeight: -1, + defaultWidth: 230, + defaultHeight: 180, +}; + +interface FileTreeShapeProps extends ShapeProps { + text: string; +} + +const shapeType: ShapeType = 'fileTree'; + +export const getFileTreeShapeSizeRestrictions = (): ShapeSizeRestrictions => + fileTreeShapeRestrictions; + +export const FileTreeShape = forwardRef( + (props, ref) => { + const { + x, + y, + width, + height, + id, + onSelected, + text, + otherProps, + ...shapeProps + } = props; + + const treeItems = useMemo(() => { + return parseFileTreeText(text); + }, [text]); + + const [icons, setIcons] = useState>( + { + folder: null, + subfolder: null, + file: null, + } + ); + + const { + fontSize, + iconDimension, + elementHeight, + extraTextTopPadding, + paddingX, + paddingY, + iconTextSpacing, + indentationStep, + } = useMemo( + () => getFileTreeSizeValues(otherProps?.size), + [otherProps?.size] + ); + + const restrictedSize = calculateFileTreeDynamicSize(treeItems, { + width, + height, + elementHeight, + paddingY, + paddingX, + iconDimension, + indentationStep, + baseRestrictions: fileTreeShapeRestrictions, + }); + + useFileTreeResize( + id, + { x, y }, + text, + { width, height }, + restrictedSize, + fileTreeShapeRestrictions.minHeight, + treeItems, + { + fontSize, + iconDimension, + elementHeight, + extraTextTopPadding, + paddingX, + paddingY, + iconTextSpacing, + indentationStep, + }, + otherProps?.size + ); + + const { width: restrictedWidth, height: restrictedHeight } = restrictedSize; + + const { stroke, strokeStyle, fill, textColor, borderRadius } = + useShapeProps(otherProps, BASIC_SHAPE); + + const commonGroupProps = useGroupShapeProps( + props, + restrictedSize, + shapeType, + ref + ); + + // Helper functions for position calculations + const calculateIconX = (item: FileTreeItem) => { + return paddingX + item.level * indentationStep; + }; + + const calculateTextX = (item: FileTreeItem) => { + return calculateIconX(item) + iconDimension + iconTextSpacing; + }; + + const calculateAvailableWidth = (item: FileTreeItem) => { + return restrictedWidth - calculateTextX(item) - paddingX; + }; + + useEffect(() => { + Promise.all([ + loadSvgWithFill('/icons/folder.svg', stroke), + loadSvgWithFill('/icons/open.svg', stroke), + loadSvgWithFill('/icons/new.svg', stroke), + ]).then(([folder, subfolder, file]) => { + setIcons({ + folder, + subfolder, + file, + }); + }); + }, [stroke]); + + return ( + + {/* Container */} + + + {treeItems.map((item, index) => ( + + {icons[item.type] && ( + + )} + + + ))} + + ); + } +); diff --git a/src/common/components/mock-components/front-rich-components/index.ts b/src/common/components/mock-components/front-rich-components/index.ts index 525b3d37..b1e5f180 100644 --- a/src/common/components/mock-components/front-rich-components/index.ts +++ b/src/common/components/mock-components/front-rich-components/index.ts @@ -19,3 +19,4 @@ export * from './videoconference'; export * from './togglelightdark-shape'; export * from './gauge/gauge'; export * from './fab-button/fab-button'; +export * from './file-tree/file-tree'; diff --git a/src/core/model/index.ts b/src/core/model/index.ts index 6885155c..bf189305 100644 --- a/src/core/model/index.ts +++ b/src/core/model/index.ts @@ -85,7 +85,8 @@ export type ShapeType = | 'circleLow' | 'textScribbled' | 'paragraphScribbled' - | 'fabButton'; + | 'fabButton' + | 'fileTree'; export const ShapeDisplayName: Record = { multiple: 'multiple', @@ -160,6 +161,7 @@ export const ShapeDisplayName: Record = { textScribbled: 'Text Scribbled', paragraphScribbled: 'Paragraph Scribbled', fabButton: 'Fab Button', + fileTree: 'File Tree', }; export type EditType = 'input' | 'textarea' | 'imageupload'; @@ -189,6 +191,12 @@ export interface IconInfo { export type IconSize = 'XS' | 'S' | 'M' | 'L' | 'XL'; +export type ElementSize = 'XS' | 'S' | 'M' | 'L' | 'XL'; + +export interface SizeConfig { + availableSizes: ElementSize[]; +} + export interface OtherProps { stroke?: string; strokeStyle?: number[]; @@ -202,6 +210,7 @@ export interface OtherProps { checked?: boolean; icon?: IconInfo; iconSize?: IconSize; + size?: ElementSize; imageSrc?: string; imageBlackAndWhite?: boolean; progress?: string; diff --git a/src/pods/canvas/model/inline-editable.model.ts b/src/pods/canvas/model/inline-editable.model.ts index fd465059..bc99eecc 100644 --- a/src/pods/canvas/model/inline-editable.model.ts +++ b/src/pods/canvas/model/inline-editable.model.ts @@ -39,6 +39,7 @@ const inlineEditableShapes = new Set([ 'modalDialog', 'gauge', 'loading-indicator', + 'fileTree', ]); // Check if a shape type allows inline editing @@ -82,6 +83,7 @@ const shapeTypesWithDefaultText = new Set([ 'modalDialog', 'loading-indicator', 'gauge', + 'fileTree', ]); // Map of ShapeTypes to their default text values @@ -115,6 +117,7 @@ const defaultTextValueMap: Partial> = { gauge: '10%', buttonBar: 'Button 1, Button 2, Button 3', tabsBar: 'Tab 1, Tab 2, Tab 3', + fileTree: '+ Folder 1\n - Subfolder\n * File\n+ Folder 2\n', link: 'Link', chip: 'Chip', timepickerinput: 'hh:mm', @@ -151,6 +154,7 @@ export const getShapeEditInlineType = ( case 'appBar': case 'tabsBar': case 'tooltip': + case 'fileTree': return 'textarea'; break; case 'image': diff --git a/src/pods/canvas/model/shape-other-props.utils.ts b/src/pods/canvas/model/shape-other-props.utils.ts index fe238aea..f2c1007f 100644 --- a/src/pods/canvas/model/shape-other-props.utils.ts +++ b/src/pods/canvas/model/shape-other-props.utils.ts @@ -5,7 +5,7 @@ import { LINK_SHAPE, LOW_WIREFRAME_SHAPE, } from '@/common/components/mock-components/front-components/shape.const'; -import { ShapeType, OtherProps } from '@/core/model'; +import { ShapeType, OtherProps, SizeConfig } from '@/core/model'; export const generateDefaultOtherProps = ( shapeType: ShapeType @@ -75,6 +75,15 @@ export const generateDefaultOtherProps = ( stroke: '#808080', textColor: BASIC_SHAPE.DEFAULT_FILL_TEXT, }; + case 'fileTree': + return { + stroke: BASIC_SHAPE.DEFAULT_STROKE_COLOR, + backgroundColor: BASIC_SHAPE.DEFAULT_FILL_BACKGROUND, + textColor: BASIC_SHAPE.DEFAULT_FILL_TEXT, + strokeStyle: [], + borderRadius: `${BASIC_SHAPE.DEFAULT_CORNER_RADIUS}`, + size: 'S', + }; case 'fabButton': return { icon: { @@ -282,3 +291,20 @@ export const generateDefaultOtherProps = ( return undefined; } }; + +export const getSizeConfigForShape = ( + shapeType: ShapeType +): SizeConfig | undefined => { + switch (shapeType) { + case 'icon': + return { + availableSizes: ['XS', 'S', 'M', 'L', 'XL'], + }; + case 'fileTree': + return { + availableSizes: ['XS', 'S'], + }; + default: + return undefined; + } +}; diff --git a/src/pods/canvas/model/shape-size.mapper.ts b/src/pods/canvas/model/shape-size.mapper.ts index 397dddd5..3c4ea531 100644 --- a/src/pods/canvas/model/shape-size.mapper.ts +++ b/src/pods/canvas/model/shape-size.mapper.ts @@ -65,6 +65,7 @@ import { getVideoconferenceShapeSizeRestrictions, getGaugeShapeSizeRestrictions, getFabButtonShapeSizeRestrictions, + getFileTreeShapeSizeRestrictions, // other imports } from '@/common/components/mock-components/front-rich-components'; import { @@ -173,6 +174,7 @@ const shapeSizeMap: Record ShapeSizeRestrictions> = { textScribbled: getTextScribbledShapeRestrictions, paragraphScribbled: getParagraphScribbledShapeRestrictions, fabButton: getFabButtonShapeSizeRestrictions, + fileTree: getFileTreeShapeSizeRestrictions, }; export default shapeSizeMap; diff --git a/src/pods/canvas/shape-renderer/index.tsx b/src/pods/canvas/shape-renderer/index.tsx index b591c9f9..37a59949 100644 --- a/src/pods/canvas/shape-renderer/index.tsx +++ b/src/pods/canvas/shape-renderer/index.tsx @@ -49,6 +49,7 @@ import { renderAppBar, renderLoadingIndicator, renderFabButton, + renderFileTree, } from './simple-rich-components'; import { renderDiamond, @@ -212,6 +213,8 @@ export const renderShapeComponent = ( return renderVideoconference(shape, shapeRenderedProps); case 'fabButton': return renderFabButton(shape, shapeRenderedProps); + case 'fileTree': + return renderFileTree(shape, shapeRenderedProps); case 'gauge': return renderGauge(shape, shapeRenderedProps); case 'imagePlaceholder': diff --git a/src/pods/canvas/shape-renderer/simple-rich-components/file-tree.renderer.tsx b/src/pods/canvas/shape-renderer/simple-rich-components/file-tree.renderer.tsx new file mode 100644 index 00000000..ad40072e --- /dev/null +++ b/src/pods/canvas/shape-renderer/simple-rich-components/file-tree.renderer.tsx @@ -0,0 +1,34 @@ +import { ShapeRendererProps } from '../model'; +import { ShapeModel } from '@/core/model'; +import { FileTreeShape } from '@/common/components/mock-components/front-rich-components/file-tree/file-tree'; + +export const renderFileTree = ( + shape: ShapeModel, + shapeRenderedProps: ShapeRendererProps +) => { + const { handleSelected, shapeRefs, handleDragEnd, handleTransform } = + shapeRenderedProps; + + return ( + + ); +}; diff --git a/src/pods/canvas/shape-renderer/simple-rich-components/index.ts b/src/pods/canvas/shape-renderer/simple-rich-components/index.ts index 440b7ba4..2a32d3d1 100644 --- a/src/pods/canvas/shape-renderer/simple-rich-components/index.ts +++ b/src/pods/canvas/shape-renderer/simple-rich-components/index.ts @@ -21,3 +21,4 @@ export * from './audio-player.renderer'; export * from './loading-indicator.renderer'; export * from './videoconference.renderer'; export * from './fab-button.renderer'; +export * from './file-tree.renderer'; diff --git a/src/pods/galleries/rich-components-gallery/rich-components-gallery-data/index.ts b/src/pods/galleries/rich-components-gallery/rich-components-gallery-data/index.ts index 439b4f92..b44eee90 100644 --- a/src/pods/galleries/rich-components-gallery/rich-components-gallery-data/index.ts +++ b/src/pods/galleries/rich-components-gallery/rich-components-gallery-data/index.ts @@ -40,4 +40,8 @@ export const mockRichComponentsCollection: ItemInfo[] = [ thumbnailSrc: '/rich-components/fab-button.svg', type: 'fabButton', }, + { + thumbnailSrc: '/rich-components/file-tree.svg', + type: 'fileTree', + }, ]; diff --git a/src/pods/properties/components/select-size/index.ts b/src/pods/properties/components/select-size/index.ts index 7137538b..55e7c20a 100644 --- a/src/pods/properties/components/select-size/index.ts +++ b/src/pods/properties/components/select-size/index.ts @@ -1 +1,2 @@ export * from './select-size.component'; +export * from './select-size-v2'; diff --git a/src/pods/properties/components/select-size/select-size-v2/index.ts b/src/pods/properties/components/select-size/select-size-v2/index.ts new file mode 100644 index 00000000..83d8adc2 --- /dev/null +++ b/src/pods/properties/components/select-size/select-size-v2/index.ts @@ -0,0 +1 @@ +export * from './select-size-v2.component'; diff --git a/src/pods/properties/components/select-size/select-size-v2/select-size-v2.component.module.css b/src/pods/properties/components/select-size/select-size-v2/select-size-v2.component.module.css new file mode 100644 index 00000000..8954bc08 --- /dev/null +++ b/src/pods/properties/components/select-size/select-size-v2/select-size-v2.component.module.css @@ -0,0 +1,22 @@ +.container { + display: flex; + gap: 0.5em; + align-items: center; + padding: var(--space-xs) var(--space-md); + border-bottom: 1px solid var(--primary-300); +} + +.container :first-child { + flex: 1; +} + +.range { + cursor: pointer; + width: 60px; + height: var(--space-lg); +} + +.size { + width: 10px; + padding: 0 var(--space-md) 0 var(--space-s); +} diff --git a/src/pods/properties/components/select-size/select-size-v2/select-size-v2.component.tsx b/src/pods/properties/components/select-size/select-size-v2/select-size-v2.component.tsx new file mode 100644 index 00000000..45dd2c77 --- /dev/null +++ b/src/pods/properties/components/select-size/select-size-v2/select-size-v2.component.tsx @@ -0,0 +1,44 @@ +import { ElementSize, ShapeType } from '@/core/model'; +import classes from './select-size-v2.component.module.css'; +import { sizeToStep, stepToSize } from './select-size.utils'; +import { getSizeConfigForShape } from '@/pods/canvas/model/shape-other-props.utils'; + +// ⚠️ This is a temporary v2 component introduced to support shape-specific size customization. +// It will replace current SelectSize once migration is complete. +// For details, see issue: https://github.com/Lemoncode/quickmock/issues/791 + +interface Props { + label: string; + shapeType?: ShapeType; + value: string; + onChange: (value: ElementSize) => void; +} + +export const SelectSizeV2: React.FC = ({ + label, + shapeType, + value, + onChange, +}) => { + if (!shapeType) return null; + + const config = getSizeConfigForShape(shapeType); + + if (!config) return null; + + return ( +
+

{label}

+ onChange(stepToSize(config, e.target.value))} + className={classes.range} + /> +

{value}

+
+ ); +}; diff --git a/src/pods/properties/components/select-size/select-size-v2/select-size.utils.ts b/src/pods/properties/components/select-size/select-size-v2/select-size.utils.ts new file mode 100644 index 00000000..adb16c3e --- /dev/null +++ b/src/pods/properties/components/select-size/select-size-v2/select-size.utils.ts @@ -0,0 +1,11 @@ +import { ElementSize, SizeConfig } from '@/core/model'; + +export const sizeToStep = (config: SizeConfig, size: string): string => { + const index = config.availableSizes.indexOf(size as ElementSize); + return index >= 0 ? (index + 1).toString() : '1'; +}; + +export const stepToSize = (config: SizeConfig, step: string): ElementSize => { + const index = parseInt(step) - 1; + return config.availableSizes[index] || config.availableSizes[0]; +}; diff --git a/src/pods/properties/properties.model.ts b/src/pods/properties/properties.model.ts index 78f03746..2a91c25a 100644 --- a/src/pods/properties/properties.model.ts +++ b/src/pods/properties/properties.model.ts @@ -13,6 +13,7 @@ export const multiSelectEnabledProperties: (keyof OtherProps)[] = [ 'checked', 'icon', 'iconSize', + 'size', 'imageBlackAndWhite', 'progress', 'borderRadius', diff --git a/src/pods/properties/properties.pod.tsx b/src/pods/properties/properties.pod.tsx index fa49293f..9cc41f5d 100644 --- a/src/pods/properties/properties.pod.tsx +++ b/src/pods/properties/properties.pod.tsx @@ -3,7 +3,13 @@ import classes from './properties.pod.module.css'; import { ZIndexOptions } from './components/zindex/zindex-option.component'; import { ColorPicker } from './components/color-picker/color-picker.component'; import { Checked } from './components/checked/checked.component'; -import { SelectSize, SelectIcon, BorderRadius, Disabled } from './components'; +import { + SelectSize, + SelectIcon, + BorderRadius, + Disabled, + SelectSizeV2, +} from './components'; import { StrokeStyle } from './components/stroke-style/stroke.style.component'; import { ImageSrc } from './components/image-src/image-selector.component'; import { ImageBlackAndWhite } from './components/image-black-and-white/image-black-and-white-selector.component'; @@ -145,6 +151,23 @@ export const PropertiesPod = () => { } /> + + + + updateOtherPropsOnSelected('size', size, isMultipleSelection) + } + /> + +