diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts index dd15b414..f2e60f94 100644 --- a/e2e/helpers/index.ts +++ b/e2e/helpers/index.ts @@ -1,2 +1,3 @@ export * from './konva-testing.helpers'; export * from './position.helpers'; +export * from './properties.helpers'; diff --git a/e2e/helpers/konva-testing.helpers.ts b/e2e/helpers/konva-testing.helpers.ts index 93c8ad89..d4ba4e3d 100644 --- a/e2e/helpers/konva-testing.helpers.ts +++ b/e2e/helpers/konva-testing.helpers.ts @@ -5,6 +5,8 @@ import { Group } from 'konva/lib/Group'; import { E2E_CanvasItemKeyAttrs } from './types/e2e-types'; import { getCanvasBoundingBox } from './position.helpers'; +// MAIN CANVAS HELPERS + const getLayer = async (page: Page): Promise => await page.evaluate(() => { return window.__TESTING_KONVA_LAYER__; @@ -50,6 +52,50 @@ export const getByShapeType = async ( } }; +// THUMB HELPERS + +const getThumbLayer = async (page: Page, pageIndex: number): Promise => + await page.evaluate(index => { + return window.__TESTING_KONVA_THUMB_LAYERS__?.[index]; + }, pageIndex); + +const getThumbChildren = async (page: Page, pageIndex: number) => { + const layer = await getThumbLayer(page, pageIndex); + return layer?.children || []; +}; + +// Waits for a thumb to finish rendering (until it has at least one child) + +export const waitForThumbToRender = async ( + page: Page, + pageIndex: number, + timeout = 5000 +) => { + await page.waitForFunction( + async index => { + const layer = window.__TESTING_KONVA_THUMB_LAYERS__?.[index]; + if (!layer) return false; + + const children = layer.children || []; + return children && children.length > 0; + }, + pageIndex, + { timeout } + ); +}; + +export const getByShapeTypeInThumb = async ( + page: Page, + pageIndex: number, + shape: string +): Promise => { + await waitForThumbToRender(page, pageIndex); + + // Search for the shape + const children = await getThumbChildren(page, pageIndex); + return children?.find(child => child.attrs.shapeType === shape); +}; + export const getTransformer = async (page: Page): Promise => { const layer = await getLayer(page); const transformer = layer?.children.find((child: any) => { diff --git a/e2e/helpers/position.helpers.ts b/e2e/helpers/position.helpers.ts index c5964c90..7e519880 100644 --- a/e2e/helpers/position.helpers.ts +++ b/e2e/helpers/position.helpers.ts @@ -5,6 +5,11 @@ export interface Position { y: number; } +export interface ComponentWithCategory { + name: string; + category?: string; +} + export const getLocatorPosition = async ( locator: Locator ): Promise => { @@ -44,6 +49,18 @@ export const dragAndDrop = async ( await page.mouse.up(); }; +const getTargetPosition = ( + canvasPosition: { x: number; y: number }, + displacementQty: number, + multiplyFactor: number +): Position => { + const positionDisplacement = displacementQty * (multiplyFactor + 1); + return { + x: canvasPosition.x + displacementQty + positionDisplacement, + y: canvasPosition.y + positionDisplacement, + }; +}; + export const addComponentsToCanvas = async ( page: Page, components: string[], @@ -58,18 +75,83 @@ export const addComponentsToCanvas = async ( await component.scrollIntoViewIfNeeded(); const position = await getLocatorPosition(component); - const targetPosition = ( - displacementQty: number, - multiplyFactor: number - ) => { - const positionDisplacement = displacementQty * (multiplyFactor + 1); - return { - x: canvasPosition.x + displacementQty + positionDisplacement, - y: canvasPosition.y + positionDisplacement, - }; - }; - - await dragAndDrop(page, position, targetPosition(displacementQty, index)); + const targetPosition = getTargetPosition( + canvasPosition, + displacementQty, + index + ); + await dragAndDrop(page, position, targetPosition); + } +}; + +export const addComponentsWithDifferentCategoriesToCanvas = async ( + page: Page, + components: ComponentWithCategory[], + displacementQty: number = 120 +) => { + // Handle empty array + if (components.length === 0) { + return; + } + + const stageCanvas = await page.locator('#konva-stage canvas').nth(1); + const canvasPosition = await stageCanvas.boundingBox(); + if (!canvasPosition) throw new Error('No canvas found'); + + let currentCategory: string | undefined = undefined; + + for await (const [index, componentConfig] of components.entries()) { + try { + // Change category only if it's different from current one + if ( + componentConfig.category && + componentConfig.category !== currentCategory + ) { + const categoryButton = page.getByText(componentConfig.category, { + exact: true, + }); + + // Check if category exists before clicking + await categoryButton.waitFor({ state: 'visible', timeout: 3000 }); + await categoryButton.click(); + + // Wait a bit for the category change to take effect + await page.waitForTimeout(500); + currentCategory = componentConfig.category; + } + + // Find component with better handling for duplicates + let component = page.getByAltText(componentConfig.name, { exact: true }); + + // Check if there are multiple elements with the same alt text + const componentCount = await component.count(); + + if (componentCount > 1) { + // Handle duplicates by selecting the first visible one in the current category context + console.warn( + `Multiple components found with name "${componentConfig.name}". Using first visible one.` + ); + component = component.first(); + } + + // Wait for component to be available + await component.waitFor({ state: 'visible', timeout: 5000 }); + await component.scrollIntoViewIfNeeded(); + const position = await getLocatorPosition(component); + + const targetPosition = getTargetPosition( + canvasPosition, + displacementQty, + index + ); + await dragAndDrop(page, position, targetPosition); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to add component "${componentConfig.name}" from category "${componentConfig.category || 'default'}": ${errorMessage}` + ); + } } }; @@ -77,6 +159,26 @@ export const getShapePosition = async (shape: Group): Promise => { return { x: shape?.attrs.x, y: shape?.attrs.y }; }; +export const selectAllComponentsInCanvas = async ( + page: Page, + selectionArea?: { start: Position; end: Position } +) => { + // Clear any existing selection first + await page.mouse.click(800, 130); + + // Small delay to ensure the click is processed + await page.waitForTimeout(100); + + const selectionStart = selectionArea?.start || { x: 260, y: 130 }; + const selectionEnd = selectionArea?.end || { x: 1000, y: 650 }; + + // Perform drag selection using the proven coordinates + await dragAndDrop(page, selectionStart, selectionEnd); + + // Small delay to ensure selection is processed + await page.waitForTimeout(200); +}; + export const moveSelected = ( page: Page, direction: string, diff --git a/e2e/helpers/properties.helpers.ts b/e2e/helpers/properties.helpers.ts new file mode 100644 index 00000000..bc7225db --- /dev/null +++ b/e2e/helpers/properties.helpers.ts @@ -0,0 +1,31 @@ +import { Page, expect } from '@playwright/test'; +import { getByShapeType } from './konva-testing.helpers'; +import { Group } from 'konva/lib/Group'; + +export const getShapeBackgroundColor = async ( + page: Page, + shapeType: string +): Promise => { + const shape = (await getByShapeType(page, shapeType)) as Group; + return shape?.children?.[0]?.attrs?.fill; +}; + +export const checkPropertiesExist = async ( + page: Page, + properties: string[] +) => { + for (const property of properties) { + const propLocator = page.getByText(property, { exact: true }); + await expect(propLocator).toBeVisible(); + } +}; + +export const checkPropertiesDoNotExist = async ( + page: Page, + properties: string[] +) => { + for (const property of properties) { + const propLocator = page.getByText(property, { exact: true }); + await expect(propLocator).not.toBeVisible(); + } +}; diff --git a/e2e/props/multi-select-bg-and-common-props.spec.ts b/e2e/props/multi-select-bg-and-common-props.spec.ts new file mode 100644 index 00000000..5dd6c766 --- /dev/null +++ b/e2e/props/multi-select-bg-and-common-props.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { + getTransformer, + ComponentWithCategory, + getShapeBackgroundColor, + addComponentsWithDifferentCategoriesToCanvas, + selectAllComponentsInCanvas, + checkPropertiesExist, +} from '../helpers'; + +test('when selecting a button and a rectangle, select both, change background color to red, both should update their bg to red', async ({ + page, +}) => { + await page.goto(''); + + // Add components to canvas + const components: ComponentWithCategory[] = [ + { name: 'Button' }, // Button is in default 'Components' category + { name: 'Rectangle', category: 'Basic Shapes' }, + ]; + await addComponentsWithDifferentCategoriesToCanvas(page, components); + + // Select all components in canvas + await selectAllComponentsInCanvas(page); + + // Confirm both items are selected + const selectedItems = await getTransformer(page); + expect(selectedItems._nodes.length).toEqual(2); + + // Change background color to red + const bgSelector = page + .getByText('Background') + .locator('..') + .locator('button'); + await bgSelector.click(); + + const redColorBox = page.locator( + 'div[style*="background-color: rgb(221, 0, 0)"]' + ); + await redColorBox.click(); + + // Verify that both items have red background + const buttonBgColor = await getShapeBackgroundColor(page, 'button'); + const rectangleBgColor = await getShapeBackgroundColor(page, 'rectangle'); + + expect(buttonBgColor).toBe('#DD0000'); + expect(rectangleBgColor).toBe('#DD0000'); +}); + +test('verify that in the props we can find the common props of both items', async ({ + page, +}) => { + await page.goto(''); + + // Add components to canvas + const components: ComponentWithCategory[] = [ + { name: 'Button' }, + { name: 'Rectangle', category: 'Basic Shapes' }, + ]; + await addComponentsWithDifferentCategoriesToCanvas(page, components); + + // Select all components in canvas + await selectAllComponentsInCanvas(page); + + // Confirm both items are selected + const selectedItems = await getTransformer(page); + expect(selectedItems._nodes.length).toEqual(2); + + const commonProps: string[] = [ + 'Layering', + 'Stroke', + 'Stroke style', + 'Background', + 'Border-radius', + ]; + + // Verify common properties are visible in the properties panel + await checkPropertiesExist(page, commonProps); +}); diff --git a/e2e/props/multi-select-no-common-props.spec.ts b/e2e/props/multi-select-no-common-props.spec.ts new file mode 100644 index 00000000..b87697da --- /dev/null +++ b/e2e/props/multi-select-no-common-props.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; +import { + addComponentsWithDifferentCategoriesToCanvas, + checkPropertiesDoNotExist, + checkPropertiesExist, + ComponentWithCategory, + getTransformer, + selectAllComponentsInCanvas, +} from '../helpers'; + +test('when selecting button and bar chart, check that there are not common props (just default layering prop)', async ({ + page, +}) => { + page.goto(''); + + // Add components to canvas + const components: ComponentWithCategory[] = [ + { name: 'Button' }, + { name: 'Bar Chart', category: 'Rich Components' }, + ]; + await addComponentsWithDifferentCategoriesToCanvas(page, components); + + // Select all components in canvas + await selectAllComponentsInCanvas(page); + + // Confirm both items are selected + const selectedItems = await getTransformer(page); + expect(selectedItems._nodes.length).toEqual(2); + + const buttonProps: string[] = [ + 'Stroke', + 'Stroke style', + 'Background', + 'TextColor', + 'Disabled', + 'Border-radius', + ]; + + // Verify button props are not visible in the properties panel + await checkPropertiesDoNotExist(page, buttonProps); + + // Verify layering prop to be visible + + await checkPropertiesExist(page, ['Layering']); +}); diff --git a/e2e/thumb-pages/change-thumb-selected-add-component.spec.ts b/e2e/thumb-pages/change-thumb-selected-add-component.spec.ts new file mode 100644 index 00000000..46f45f7d --- /dev/null +++ b/e2e/thumb-pages/change-thumb-selected-add-component.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { dragAndDrop, getByShapeType, getLocatorPosition } from '../helpers'; + +test('when selecting a new thumb and adding a new shape it will be dropped in the new created page', async ({ + page, +}) => { + await page.goto(''); + + await page.getByText('Devices').click(); + + // Drag & drop mobile device component to default page canvas + const componentFirstPage = page + .getByText('Mobile Phone', { exact: true }) + .locator('..'); + + const position = await getLocatorPosition(componentFirstPage); + const targetPosition = { x: position.x + 500, y: position.y - 200 }; + await dragAndDrop(page, position, targetPosition); + + // Add new page + await page.getByText('Pages').click(); + const addButton = page.getByRole('button', { name: 'add new page' }); + await addButton.click(); + + // Verify Page 2 is automatically selected/active + const pageTwo = page.getByText('Page 2', { exact: true }).locator('..'); + await expect(pageTwo).toHaveClass(/active/); + + // Drag & drop browser component to second page canvas + await page.getByText('Devices').click(); + const componentSecondPage = page + .getByText('Browser', { exact: true }) + .locator('..'); + + const position2 = await getLocatorPosition(componentSecondPage); + const targetPosition2 = { x: position2.x + 400, y: position2.y }; + await dragAndDrop(page, position2, targetPosition2); + + await page.getByText('Pages').click(); + + // Verify if browser is in page 2 (current selected page) + const browser = await getByShapeType(page, 'browser'); + expect(browser).toBeDefined(); + + // Switch to page 1 + const pageOne = page.getByText('Page 1', { exact: true }).locator('..'); + await pageOne.click(); + + // Verify if in page 1: mobile visible, browser not visible + const mobile = await getByShapeType(page, 'mobilePhone'); + const browserInPage1 = await getByShapeType(page, 'browser'); + + expect(mobile).toBeDefined(); + expect(browserInPage1).toBeUndefined(); + + // Add button to page 1 to verify shapes are added to correct active page + await page.getByText('Components', { exact: true }).click(); + const button = page.getByAltText('Button', { exact: true }); + + const position3 = await getLocatorPosition(button); + const targetPosition3 = { x: position.x + 500, y: position.y - 100 }; + await dragAndDrop(page, position3, targetPosition3); + + // Verify button was added to canvas in page 1 + const buttonInCanvas = await getByShapeType(page, 'button'); + expect(mobile).toBeDefined(); + expect(buttonInCanvas).toBeDefined(); +}); diff --git a/e2e/thumb-pages/delete-button-disabled-when-only-one-thumb-page.spec.ts b/e2e/thumb-pages/delete-button-disabled-when-only-one-thumb-page.spec.ts new file mode 100644 index 00000000..1f7bd28f --- /dev/null +++ b/e2e/thumb-pages/delete-button-disabled-when-only-one-thumb-page.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; + +test('when there is just one thumb page active, delete thumb page button has to be disabled', async ({ + page, +}) => { + await page.goto(''); + + // Open Pages panel + await page.getByText('Pages').click(); + + // Add two more pages + const addButton = page.getByRole('button', { name: 'add new page' }); + await addButton.click(); + await addButton.click(); + + // Delete page 3 + const deleteButton = page.locator('div').filter({ hasText: /^Delete$/ }); + + const pageThree = page.getByText('Page 3', { exact: true }); + expect(pageThree).toBeVisible(); + const thumbThreeDiv = pageThree.locator('..'); + await thumbThreeDiv.click({ button: 'right' }); + await deleteButton.click(); + + // Delete page 2 + const pageTwo = page.getByText('Page 2', { exact: true }); + expect(pageTwo).toBeVisible(); + const thumbTwoDiv = pageTwo.locator('..'); + await thumbTwoDiv.click({ button: 'right' }); + await deleteButton.click(); + + // Try to delete page 1 + const pageOne = page.getByText('Page 1', { exact: true }); + expect(pageOne).toBeVisible(); + const thumbOneDiv = pageOne.locator('..'); + await thumbOneDiv.click({ button: 'right' }); + + // Check if delete button is disabled + await expect(deleteButton).toHaveClass(/disabled/); +}); diff --git a/e2e/thumb-pages/delete-given-thumb-page.spec.ts b/e2e/thumb-pages/delete-given-thumb-page.spec.ts new file mode 100644 index 00000000..7d0133b9 --- /dev/null +++ b/e2e/thumb-pages/delete-given-thumb-page.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { addComponentsToCanvas } from '../helpers'; + +test('should delete one of the created thumb pages and just 2 thumb pages should remain', async ({ + page, +}) => { + await page.goto(''); + + // Add a browser component to first page + await page.getByText('Devices').click(); + + await addComponentsToCanvas(page, ['Browser']); + + // Add a second page + await page.getByText('Pages').click(); + const addButton = page.getByRole('button', { name: 'add new page' }); + await addButton.click(); + + // Add mobile phone component to second page + + await page.getByText('Devices').click(); + + await addComponentsToCanvas(page, ['Mobile Phone']); + + // Add a third page + + await page.getByText('Pages').click(); + + await addButton.click(); + + // Add a tablet component to third page + + await page.getByText('Devices').click(); + + await addComponentsToCanvas(page, ['Tablet']); + + // Delete page 2 + await page.getByText('Pages').click(); + + const pageTwo = page.getByText('Page 2', { exact: true }); + const thumbTwoDiv = pageTwo.locator('..'); + await thumbTwoDiv.click({ button: 'right' }); + + const deleteButton = page.locator('div').filter({ hasText: /^Delete$/ }); + + await deleteButton.click(); + + // Verify page 2 does not exist + + expect(pageTwo).not.toBeVisible(); + + // Verify page 1 and 3 exist + + const pageOne = page.getByText('Page 1', { exact: true }); + + const pageThree = page.getByText('Page 3', { exact: true }); + + await expect(pageOne).toBeVisible(); + await expect(pageThree).toBeVisible(); + + // Verify total pages + + const allPages = page.getByText(/^Page \d+$/); + await expect(allPages).toHaveCount(2); +}); diff --git a/e2e/thumb-pages/dropped-button-visible-on-thumb.spec.ts b/e2e/thumb-pages/dropped-button-visible-on-thumb.spec.ts new file mode 100644 index 00000000..f414c844 --- /dev/null +++ b/e2e/thumb-pages/dropped-button-visible-on-thumb.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test'; +import { + dragAndDrop, + getByShapeTypeInThumb, + getLocatorPosition, +} from '../helpers'; + +test('drop button in canvas, ensure is visible on thumb', async ({ page }) => { + await page.goto(''); + + // Drag & drop button in canvas + const button = page.getByAltText('Button', { exact: true }); + await expect(button).toBeVisible(); + + const position = await getLocatorPosition(button); + const targetPosition = { x: position.x + 500, y: position.y }; + await dragAndDrop(page, position, targetPosition); + + // Open Pages panel + await page.getByText('Pages').click(); + + // Verify button is visible in thumb + const buttonInThumb = await getByShapeTypeInThumb(page, 0, 'button'); + expect(buttonInThumb).toBeDefined(); +}); diff --git a/e2e/thumb-pages/duplicate-thumb-page-from-arrow-icon.spec.ts b/e2e/thumb-pages/duplicate-thumb-page-from-arrow-icon.spec.ts new file mode 100644 index 00000000..99d57fb1 --- /dev/null +++ b/e2e/thumb-pages/duplicate-thumb-page-from-arrow-icon.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; +import { + addComponentsToCanvas, + getByShapeType, + getByShapeTypeInThumb, +} from '../helpers'; +import { Group } from 'konva/lib/Group'; + +test('should duplicate thumbpage when triggered via the arrow icon', async ({ + page, +}) => { + await page.goto(''); + + // Add components to canvas + await addComponentsToCanvas(page, ['Button', 'Combobox']); + + await page.getByText('Pages').click(); + + // Find thumb page + const siblingElement = page.getByText('Page 1', { exact: true }); + const thumb = siblingElement.locator('..'); + + // Select arrow svg inside the thumb container + const svgElement = thumb.locator('span > svg'); + await svgElement.click(); + + // Verify duplicate button exists in the context menu + const duplicateButton = page + .locator('div') + .filter({ hasText: /^Duplicate$/ }); + + await expect(duplicateButton).toBeVisible(); + + // Duplicate thumbpage + await duplicateButton.click(); + + // Verify Page 1 - copy exists and its selected + const pageTwo = page + .getByText('Page 1 - copy', { exact: true }) + .locator('..'); + await expect(pageTwo).toBeVisible(); + + // Additional click to force the duplicate thumbnail to finish rendering. + await pageTwo.click(); + // Without this click, the React/Konva pipeline does not always update on time and the + // assertions fail intermittently. + + // Verify components exist in copy thumb + const buttonInCopyThumb = await getByShapeTypeInThumb(page, 1, 'button'); + const comboboxInCopyThumb = await getByShapeTypeInThumb(page, 1, 'combobox'); + expect(buttonInCopyThumb).toBeDefined(); + expect(comboboxInCopyThumb).toBeDefined(); + + // Verify components exist in copy canvas + const buttonShape = (await getByShapeType(page, 'button')) as Group; + const comboboxShape = (await getByShapeType(page, 'combobox')) as Group; + expect(buttonShape).toBeDefined(); + expect(comboboxShape).toBeDefined(); +}); diff --git a/e2e/thumb-pages/duplicate-thumb-page-right-click-button.spec.ts b/e2e/thumb-pages/duplicate-thumb-page-right-click-button.spec.ts new file mode 100644 index 00000000..2ffe1e20 --- /dev/null +++ b/e2e/thumb-pages/duplicate-thumb-page-right-click-button.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { + addComponentsToCanvas, + getByShapeType, + getByShapeTypeInThumb, +} from '../helpers'; +import { Group } from 'konva/lib/Group'; + +test('should duplicate thumbpage when trigerred via right click', async ({ + page, +}) => { + await page.goto(''); + + // Add components to canvas + await addComponentsToCanvas(page, ['Button', 'Input']); + + await page.getByText('Pages').click(); + + // Find thumb page and right click + const siblingElement = page.getByText('Page 1', { exact: true }); + const thumb = siblingElement.locator('..'); + await thumb.click({ button: 'right' }); + + const duplicateButton = page + .locator('div') + .filter({ hasText: /^Duplicate$/ }); + + await expect(duplicateButton).toBeVisible(); + + // Duplicate thumbpage + await duplicateButton.click(); + + // Verify Page 1 - copy exists and its selected + const pageTwo = page + .getByText('Page 1 - copy', { exact: true }) + .locator('..'); + await expect(pageTwo).toBeVisible(); + + // Additional click to force the duplicate thumbnail to finish rendering. + await pageTwo.click(); + // Without this click, the React/Konva pipeline does not always update on time and the + // assertions fail intermittently. + + // Verify components exist in copy thumb + const buttonInCopyThumb = await getByShapeTypeInThumb(page, 1, 'button'); + const inputInCopyThumb = await getByShapeTypeInThumb(page, 1, 'input'); + expect(buttonInCopyThumb).toBeDefined(); + expect(inputInCopyThumb).toBeDefined(); + + // Verify components exist in copy canvas + const buttonShape = (await getByShapeType(page, 'button')) as Group; + const inputShape = (await getByShapeType(page, 'input')) as Group; + expect(buttonShape).toBeDefined(); + expect(inputShape).toBeDefined(); +}); diff --git a/global.d.ts b/global.d.ts index a54a40ab..97c66570 100644 --- a/global.d.ts +++ b/global.d.ts @@ -3,5 +3,6 @@ import Konva from 'konva'; declare global { interface Window { __TESTING_KONVA_LAYER__: Konva.Layer; + __TESTING_KONVA_THUMB_LAYERS__: Konva.Layer[]; } } diff --git a/public/rich-components/fab-button.svg b/public/rich-components/fab-button.svg new file mode 100644 index 00000000..728ecd63 --- /dev/null +++ b/public/rich-components/fab-button.svg @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/common/components/mock-components/front-components/icon/icon-shape.business.ts b/src/common/components/mock-components/front-components/icon/icon-shape.business.ts index 9378b3e3..71b7c376 100644 --- a/src/common/components/mock-components/front-components/icon/icon-shape.business.ts +++ b/src/common/components/mock-components/front-components/icon/icon-shape.business.ts @@ -1,20 +1,5 @@ import { IconSize } from '@/core/model'; -export const loadSvgWithFill = async (url: string, fillColor: string) => { - const response = await fetch(url); - const svgText = await response.text(); - - const modifiedSvg = svgText.replace(/fill="[^"]*"/g, `fill="${fillColor}"`); - - const svgBlob = new Blob([modifiedSvg], { type: 'image/svg+xml' }); - const objectURL = URL.createObjectURL(svgBlob); - - const img = new window.Image(); - img.src = objectURL; - - return img; -}; - export const returnIconSize = (iconSize: IconSize): number[] => { switch (iconSize) { case 'XS': diff --git a/src/common/components/mock-components/front-components/icon/icon-shape.tsx b/src/common/components/mock-components/front-components/icon/icon-shape.tsx index f268a9ae..86af4394 100644 --- a/src/common/components/mock-components/front-components/icon/icon-shape.tsx +++ b/src/common/components/mock-components/front-components/icon/icon-shape.tsx @@ -7,7 +7,8 @@ import { useModalDialogContext } from '@/core/providers/model-dialog-providers/m import { IconModal } from '@/pods/properties/components/icon-selector/modal'; import { useCanvasContext } from '@/core/providers'; import { useGroupShapeProps } from '../../mock-components.utils'; -import { loadSvgWithFill, returnIconSize } from './icon-shape.business'; +import { returnIconSize } from './icon-shape.business'; +import { loadSvgWithFill } from '@/common/utils/svg.utils'; const iconShapeRestrictions: ShapeSizeRestrictions = { minWidth: 25, diff --git a/src/common/components/mock-components/front-rich-components/fab-button/fab-button.tsx b/src/common/components/mock-components/front-rich-components/fab-button/fab-button.tsx new file mode 100644 index 00000000..29c11261 --- /dev/null +++ b/src/common/components/mock-components/front-rich-components/fab-button/fab-button.tsx @@ -0,0 +1,96 @@ +import { BASE_ICONS_URL, ShapeSizeRestrictions, ShapeType } from '@/core/model'; +import { forwardRef, useEffect, useState } from 'react'; +import { Circle, Group, Image } from 'react-konva'; +import { ShapeProps } from '../../shape.model'; +import { fitSizeToShapeSizeRestrictions } from '@/common/utils/shapes'; +import { useShapeProps } from '@/common/components/shapes/use-shape-props.hook'; +import { useGroupShapeProps } from '../../mock-components.utils'; +import { BASIC_SHAPE } from '../../front-components/shape.const'; +import { IconModal } from '@/pods/properties/components/icon-selector/modal'; +import { useModalDialogContext } from '@/core/providers/model-dialog-providers/model-dialog.provider'; +import { useCanvasContext } from '@/core/providers'; +import { loadSvgWithFill } from '@/common/utils/svg.utils'; + +const fabButtonShapeRestrictions: ShapeSizeRestrictions = { + minWidth: 25, + minHeight: 25, + maxWidth: -1, + maxHeight: -1, + defaultWidth: 85, + defaultHeight: 85, +}; + +const shapeType: ShapeType = 'fabButton'; + +export const getFabButtonShapeSizeRestrictions = (): ShapeSizeRestrictions => + fabButtonShapeRestrictions; + +export const FabButtonShape = forwardRef((props, ref) => { + const { x, y, width, height, id, onSelected, otherProps, ...shapeProps } = + props; + + const [iconImage, setIconImage] = useState(null); + + const { openModal } = useModalDialogContext(); + const { selectionInfo } = useCanvasContext(); + const { updateOtherPropsOnSelected } = selectionInfo; + + const restrictedSize = fitSizeToShapeSizeRestrictions( + fabButtonShapeRestrictions, + width, + height + ); + const { width: restrictedWidth, height: restrictedHeight } = restrictedSize; + + const radius = Math.min(restrictedWidth, restrictedHeight) / 2; + const center = radius; + + const iconInfo = otherProps?.icon; + const iconSize = radius * 1.2; + const iconStroke = otherProps?.stroke || '#ffffff'; + + const { fill } = useShapeProps(otherProps, BASIC_SHAPE); + const commonGroupProps = useGroupShapeProps( + props, + restrictedSize, + shapeType, + ref + ); + + const handleDoubleClick = () => { + if (iconInfo) { + openModal( + updateOtherPropsOnSelected('icon', icon)} + />, + 'Choose Icon' + ); + } + }; + + useEffect(() => { + if (iconInfo?.filename) { + loadSvgWithFill(`${BASE_ICONS_URL}${iconInfo.filename}`, iconStroke).then( + img => setIconImage(img) + ); + } + }, [iconInfo?.filename, iconStroke]); + + return ( + + {/* Background Circle */} + + {/* Icon */} + {iconImage && ( + + )} + + ); +}); 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 6664c0ae..525b3d37 100644 --- a/src/common/components/mock-components/front-rich-components/index.ts +++ b/src/common/components/mock-components/front-rich-components/index.ts @@ -18,3 +18,4 @@ export * from './loading-indicator'; export * from './videoconference'; export * from './togglelightdark-shape'; export * from './gauge/gauge'; +export * from './fab-button/fab-button'; diff --git a/src/common/utils/svg.utils.ts b/src/common/utils/svg.utils.ts new file mode 100644 index 00000000..573c60b5 --- /dev/null +++ b/src/common/utils/svg.utils.ts @@ -0,0 +1,14 @@ +export const loadSvgWithFill = async (url: string, fillColor: string) => { + const response = await fetch(url); + const svgText = await response.text(); + + const modifiedSvg = svgText.replace(/fill="[^"]*"/g, `fill="${fillColor}"`); + + const svgBlob = new Blob([modifiedSvg], { type: 'image/svg+xml' }); + const objectURL = URL.createObjectURL(svgBlob); + + const img = new window.Image(); + img.src = objectURL; + + return img; +}; diff --git a/src/core/model/index.ts b/src/core/model/index.ts index 97dd6e3f..6885155c 100644 --- a/src/core/model/index.ts +++ b/src/core/model/index.ts @@ -84,7 +84,8 @@ export type ShapeType = | 'rectangleLow' | 'circleLow' | 'textScribbled' - | 'paragraphScribbled'; + | 'paragraphScribbled' + | 'fabButton'; export const ShapeDisplayName: Record = { multiple: 'multiple', @@ -158,6 +159,7 @@ export const ShapeDisplayName: Record = { circleLow: 'Circle', textScribbled: 'Text Scribbled', paragraphScribbled: 'Paragraph Scribbled', + fabButton: 'Fab Button', }; export type EditType = 'input' | 'textarea' | 'imageupload'; diff --git a/src/pods/canvas/model/shape-other-props.utils.ts b/src/pods/canvas/model/shape-other-props.utils.ts index d10ff84b..fe238aea 100644 --- a/src/pods/canvas/model/shape-other-props.utils.ts +++ b/src/pods/canvas/model/shape-other-props.utils.ts @@ -75,6 +75,17 @@ export const generateDefaultOtherProps = ( stroke: '#808080', textColor: BASIC_SHAPE.DEFAULT_FILL_TEXT, }; + case 'fabButton': + return { + icon: { + name: 'chat', + filename: 'chat.svg', + searchTerms: ['chat', 'message', 'conversation', 'chatting'], + categories: ['IT'], + }, + stroke: '#ffffff', + backgroundColor: '#A9A9A9', + }; case 'buttonBar': return { stroke: BASIC_SHAPE.DEFAULT_STROKE_COLOR, diff --git a/src/pods/canvas/model/shape-size.mapper.ts b/src/pods/canvas/model/shape-size.mapper.ts index e4f8fd74..397dddd5 100644 --- a/src/pods/canvas/model/shape-size.mapper.ts +++ b/src/pods/canvas/model/shape-size.mapper.ts @@ -64,6 +64,7 @@ import { getVideoPlayerShapeSizeRestrictions, getVideoconferenceShapeSizeRestrictions, getGaugeShapeSizeRestrictions, + getFabButtonShapeSizeRestrictions, // other imports } from '@/common/components/mock-components/front-rich-components'; import { @@ -171,6 +172,7 @@ const shapeSizeMap: Record ShapeSizeRestrictions> = { circleLow: getCircleLowShapeSizeRestrictions, textScribbled: getTextScribbledShapeRestrictions, paragraphScribbled: getParagraphScribbledShapeRestrictions, + fabButton: getFabButtonShapeSizeRestrictions, }; export default shapeSizeMap; diff --git a/src/pods/canvas/shape-renderer/index.tsx b/src/pods/canvas/shape-renderer/index.tsx index 7b06e31c..b591c9f9 100644 --- a/src/pods/canvas/shape-renderer/index.tsx +++ b/src/pods/canvas/shape-renderer/index.tsx @@ -48,6 +48,7 @@ import { renderCalendar, renderAppBar, renderLoadingIndicator, + renderFabButton, } from './simple-rich-components'; import { renderDiamond, @@ -209,6 +210,8 @@ export const renderShapeComponent = ( return renderLoadingIndicator(shape, shapeRenderedProps); case 'videoconference': return renderVideoconference(shape, shapeRenderedProps); + case 'fabButton': + return renderFabButton(shape, shapeRenderedProps); case 'gauge': return renderGauge(shape, shapeRenderedProps); case 'imagePlaceholder': diff --git a/src/pods/canvas/shape-renderer/simple-rich-components/fab-button.renderer.tsx b/src/pods/canvas/shape-renderer/simple-rich-components/fab-button.renderer.tsx new file mode 100644 index 00000000..32c88469 --- /dev/null +++ b/src/pods/canvas/shape-renderer/simple-rich-components/fab-button.renderer.tsx @@ -0,0 +1,32 @@ +import { ShapeRendererProps } from '../model'; +import { ShapeModel } from '@/core/model'; +import { FabButtonShape } from '@/common/components/mock-components/front-rich-components/fab-button/fab-button'; + +export const renderFabButton = ( + 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 81078779..440b7ba4 100644 --- a/src/pods/canvas/shape-renderer/simple-rich-components/index.ts +++ b/src/pods/canvas/shape-renderer/simple-rich-components/index.ts @@ -20,3 +20,4 @@ export * from './video-player.renderer'; export * from './audio-player.renderer'; export * from './loading-indicator.renderer'; export * from './videoconference.renderer'; +export * from './fab-button.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 c8ceb040..439b4f92 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 @@ -36,4 +36,8 @@ export const mockRichComponentsCollection: ItemInfo[] = [ thumbnailSrc: '/rich-components/videoconference.svg', type: 'videoconference', }, + { + thumbnailSrc: '/rich-components/fab-button.svg', + type: 'fabButton', + }, ]; diff --git a/src/pods/thumb-pages/components/thumb-page.tsx b/src/pods/thumb-pages/components/thumb-page.tsx index 64719202..4334ef57 100644 --- a/src/pods/thumb-pages/components/thumb-page.tsx +++ b/src/pods/thumb-pages/components/thumb-page.tsx @@ -12,6 +12,8 @@ import classes from './thumb-page.module.css'; import React from 'react'; import { useDragDropThumb } from './drag-drop-thumb.hook'; +import Konva from 'konva'; +import { ENV } from '@/core/constants'; interface Props { pageIndex: number; @@ -35,6 +37,7 @@ export const ThumbPage: React.FunctionComponent = props => { }); const divRef = useRef(null); + const layerRef = useRef(null); const [key, setKey] = React.useState(0); const { dragging, isDraggedOver } = useDragDropThumb(divRef, pageIndex); @@ -52,6 +55,15 @@ export const ThumbPage: React.FunctionComponent = props => { }, 100); }; + // Exposing thumb layer for testing + + if (typeof window !== 'undefined' && ENV.IS_TEST_ENV && layerRef.current) { + if (!window.__TESTING_KONVA_THUMB_LAYERS__) { + window.__TESTING_KONVA_THUMB_LAYERS__ = []; + } + window.__TESTING_KONVA_THUMB_LAYERS__[pageIndex] = layerRef.current; + } + React.useLayoutEffect(() => { handleResizeAndForceRedraw(); }, []); @@ -105,7 +117,7 @@ export const ThumbPage: React.FunctionComponent = props => { scaleX={finalScale} scaleY={finalScale} > - + {shapes.map(shape => { if (!fakeShapeRefs.current[shape.id]) { fakeShapeRefs.current[shape.id] = createRef();