diff --git a/apps/client/e2e/tests/library.spec.ts b/apps/client/e2e/tests/library.spec.ts new file mode 100644 index 0000000..3bb4cd4 --- /dev/null +++ b/apps/client/e2e/tests/library.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from '@playwright/test'; +import { LOCAL_STORAGE_LIBRARY_KEY } from '@/constants/app'; +import { DRAWING_CANVAS } from '@/constants/canvas'; +import { createNode } from '@/utils/node'; +import { drawShape, getDrawingCanvas } from 'e2e/utils/canvas'; +import type Konva from 'konva'; +import type { Library } from '@/constants/app'; + +test.describe('library', async () => { + const ellipse = createNode('ellipse', [600, 600]); + ellipse.nodeProps.width = 50; + ellipse.nodeProps.height = 50; + + const rect = createNode('rectangle', [400, 400]); + rect.nodeProps.width = 50; + rect.nodeProps.height = 50; + + const library: Library = { + items: [ + { id: '1', created: Date.now(), elements: [ellipse] }, + { id: '2', created: Date.now(), elements: [rect] }, + ], + }; + + const preloadedLibrary = { + key: LOCAL_STORAGE_LIBRARY_KEY, + value: JSON.stringify(library), + }; + + test.beforeEach(async ({ page }) => { + await page.addInitScript( + ({ key, value }) => localStorage.setItem(key, value), + preloadedLibrary, + ); + + await page.goto('/'); + }); + + test('loads library state from localStorage', async ({ page }) => { + await page.getByTestId('library-drawer-trigger').click(); + + await expect(page.getByTestId('library-item').first()).toBeVisible(); + await expect(page.getByTestId('library-item').nth(1)).toBeVisible(); + await expect(page.getByTestId('library-item')).toHaveCount(2); + }); + + test('adds items to the library', async ({ page }) => { + await drawShape( + page, + [ + [500, 500], + [550, 550], + ], + { type: 'rectangle' }, + ); + + // open context menu + await getDrawingCanvas(page).click({ + button: 'right', + position: { x: 500, y: 500 }, + }); + + await page.getByText('Add To Library').click(); + + await page.getByTestId('library-drawer-trigger').click(); + + const storedLibrary: Library = await page.evaluate( + (key) => JSON.parse(window.localStorage.getItem(key) as string), + LOCAL_STORAGE_LIBRARY_KEY, + ); + + expect(storedLibrary.items).toHaveLength(3); + await expect(page.getByTestId('library-item').first()).toBeVisible(); + await expect(page.getByTestId('library-item').nth(1)).toBeVisible(); + await expect(page.getByTestId('library-item').nth(2)).toBeVisible(); + await expect(page.getByTestId('library-item')).toHaveCount(3); + await expect(page.getByText('Empty here...')).toBeHidden(); + }); + + test('removes items from library', async ({ page }) => { + await page.getByTestId('library-drawer-trigger').click(); + + for (const item of await page.getByTestId('library-item').all()) { + await item.hover(); + await item.getByRole('checkbox').check(); + } + + await page.getByTestId('remove-library-items-button').click(); + + + const storedLibrary: Library = await page.evaluate( + (key) => JSON.parse(window.localStorage.getItem(key) as string), + LOCAL_STORAGE_LIBRARY_KEY, + ); + + expect(storedLibrary.items).toHaveLength(0); + await expect(page.getByText('Empty here...')).toBeVisible(); + await expect(page.getByTestId('library-item')).toHaveCount(0); + await expect(page.getByTestId('library-item')).toBeHidden(); + }); + + // [TODO]: need better way to get all shapes + test('drag-n-drop items to canvas', async ({ page }) => { + await page.getByTestId('library-drawer-trigger').click(); + + await page + .getByTestId('library-item') + .first() + .dragTo(getDrawingCanvas(page), { targetPosition: { x: 200, y: 200 } }); + + await page + .getByTestId('library-item') + .nth(1) + .dragTo(getDrawingCanvas(page), { targetPosition: { x: 400, y: 400 } }); + + const shapes = await page.evaluate((name) => { + const drawingCanvas = window.Konva.stages.find( + (stage) => stage.name() === name, + ); + + // eslint-disable-next-line playwright/no-unsafe-references + return drawingCanvas?.find((node: Konva.Node) => Boolean(node.id())); + }, DRAWING_CANVAS.NAME); + + expect(shapes).toHaveLength(2); + }); + + test('adds items to canvas on click', async ({ page }) => { + await page.getByTestId('library-drawer-trigger').click(); + + await page.getByTestId('library-item').first().click(); + await page.getByTestId('library-item').nth(1).click(); + + const shapes = await page.evaluate((name) => { + const drawingCanvas = window.Konva.stages.find( + (stage) => stage.name() === name, + ); + + // eslint-disable-next-line playwright/no-unsafe-references + return drawingCanvas?.find((node: Konva.Node) => Boolean(node.id())); + }, DRAWING_CANVAS.NAME); + + expect(shapes).toHaveLength(2); + }); +}); diff --git a/apps/client/e2e/utils/canvas.ts b/apps/client/e2e/utils/canvas.ts index 89ba6f2..d61c2f6 100644 --- a/apps/client/e2e/utils/canvas.ts +++ b/apps/client/e2e/utils/canvas.ts @@ -4,17 +4,18 @@ import type { NodeType, Point } from 'shared'; type DrawPoints = [start: Point, end: Point]; type DrawShapeOptions = { - type: Omit; + type: Exclude; unselect?: boolean; }; type CreateTextOptions = { text: string; unselect?: boolean; }; + export async function draw(page: Page, [start, end]: DrawPoints) { await page.mouse.move(start[0], start[1]); await page.mouse.down(); - await page.mouse.move(end[0], end[1]); + await page.mouse.move(end[0], end[1], { steps: 2 }); await page.mouse.up(); } diff --git a/apps/client/src/__tests__/library.test.tsx b/apps/client/src/__tests__/library.test.tsx deleted file mode 100644 index f0832d1..0000000 --- a/apps/client/src/__tests__/library.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import App from '@/App'; -import { fireEvent, screen, within } from '@testing-library/react'; -import { findCanvas, renderWithProviders } from '@/test/test-utils'; -import { libraryGenerator, stateGenerator } from '@/test/data-generators'; -import { createNode } from '@/utils/node'; - -describe('library', () => { - // TODO - it.skip('adds shapes to the library', async () => { - // preloaded node - const node = createNode('rectangle', [50, 50]); - node.nodeProps.width = 20; - node.nodeProps.height = 20; - - const preloadedState = stateGenerator({ - canvas: { - present: { - nodes: [node], - selectedNodeIds: { [node.nodeProps.id]: true }, - }, - }, - }); - - const { user, store } = renderWithProviders(, { preloadedState }); - - const { canvas } = await findCanvas(); - - // open context menu - fireEvent.contextMenu(canvas, { clientX: 50, clientY: 50 }); - - // add to library - await user.click(screen.getByText(/Add to library/)); - - // open library drawer - await user.click(screen.getByText(/Library/)); - - const libraryItem = screen.getByTestId(/library-item/); - - const state = store.getState().library; - - expect(libraryItem).toBeInTheDocument(); - expect(state.items).toHaveLength(1); - expect(state.items[0].elements).toHaveLength(1); - expect(state.items[0].elements[0]).toEqual(node); - }); - - it('removes shapes from the library', async () => { - // preload library state - const library = libraryGenerator(3); - const preloadedState = stateGenerator({ library }); - - const { user, store } = renderWithProviders(, { preloadedState }); - - // open library drawer - await user.click(screen.getByText(/Library/i)); - - const items = screen.getAllByTestId(/library-item/i); - - // check the items - await user.click(within(items[0]).getByRole('checkbox')); - await user.click(within(items[1]).getByRole('checkbox')); - await user.click(within(items[2]).getByRole('checkbox')); - - // click on remove - await user.click(screen.getByTestId(/remove-selected-items-button/i)); - - const state = store.getState().library; - - expect(state.items).toHaveLength(0); - expect(items[0]).not.toBeInTheDocument(); - expect(items[1]).not.toBeInTheDocument(); - expect(items[2]).not.toBeInTheDocument(); - }); - - it('adds library item to the drawing canvas', async () => { - // preload library state with 1 item containing 3 shapes - const library = libraryGenerator(1, 3); - const preloadedState = stateGenerator({ library }); - - const { user, store } = renderWithProviders(, { preloadedState }); - - const { canvas } = await findCanvas(); - - const libraryTrigger = screen.getByText(/Library/i).closest('button'); - - if (libraryTrigger) { - // open library drawer - await user.click(libraryTrigger); - } - - const droppableLibraryItem = library.items[0]; - - fireEvent.drop(canvas, { - dataTransfer: { - getData: vi.fn(() => JSON.stringify(droppableLibraryItem)), - }, - clientX: 50, - clientY: 50, - }); - - const state = store.getState().canvas.present; - - expect(Object.keys(state.selectedNodeIds)).toHaveLength(3); - expect(state.nodes).toHaveLength(3); - expect(state.nodes).toEqual( - expect.arrayContaining( - droppableLibraryItem.elements.map((node) => { - return expect.objectContaining({ - ...node, - nodeProps: { - ...node.nodeProps, - id: expect.any(String), - point: expect.any(Array), - }, - }); - }), - ), - ); - }); -}); diff --git a/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx b/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx index 9e86d7b..ee0ff79 100644 --- a/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx +++ b/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx @@ -1,12 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Shape } from 'react-konva'; import { baseConfig } from '@/hooks/useNode/useNode'; import { Animation } from 'konva/lib/Animation'; -import useRefValue from '@/hooks/useRefValue/useRefValue'; import { LASER } from '@/constants/shape'; -import { calculateCurveControlPoint } from '@/utils/draw'; import { now } from '@/utils/is'; +import { calculateMidPointFromRange } from '@/utils/math'; import type { NodeComponentProps } from '@/components/Canvas/Node/Node'; +import type Konva from 'konva'; const LaserDrawable = ({ node, @@ -14,8 +14,8 @@ const LaserDrawable = ({ onNodeDelete, }: NodeComponentProps<'laser'>) => { const [path, setPath] = useState(node.nodeProps.points ?? []); - - const [lastDrawTime, setLastDrawTime] = useRefValue(now()); + + const lastDrawTime = useRef(now()); useEffect(() => { setPath((prevPath) => { @@ -30,8 +30,8 @@ const LaserDrawable = ({ return updatedPath; }); - setLastDrawTime(now()); - }, [node.nodeProps.points, setLastDrawTime, setPath]); + lastDrawTime.current = now(); + }, [node.nodeProps.points]); useEffect(() => { const pathTrimmingAnimation = new Animation((frame) => { @@ -53,7 +53,7 @@ const LaserDrawable = ({ return () => { pathTrimmingAnimation.stop(); }; - }, [lastDrawTime]); + }, []); useEffect(() => { if (!path.length) { @@ -62,43 +62,49 @@ const LaserDrawable = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, onNodeDelete]); - return ( - { - if (!path.length) return; - - const startPoint = path[0]; + const drawPath = useCallback( + (ctx: Konva.Context, shape: Konva.Shape) => { + if (!path.length) return; + + const startPoint = path[0]; + + ctx.lineCap = 'round'; + ctx.strokeStyle = LASER.COLOR; - ctx.lineCap = 'round'; - ctx.strokeStyle = LASER.COLOR; + ctx.moveTo(startPoint[0], startPoint[0]); - ctx.moveTo(startPoint[0], startPoint[0]); - ctx.beginPath(); + ctx.beginPath(); - for (const [pointIndex, point] of path.entries()) { - const prevPoint = path[pointIndex - 1] ?? point; - const controlPoint = calculateCurveControlPoint(prevPoint, point); - const pointIndexRatio = pointIndex / path.length; + for (const [index, point] of path.entries()) { + const indexRatio = index / path.length; + const prevPoint = path[index - 1] ?? point; + const controlPoint = calculateMidPointFromRange(prevPoint, point); - ctx.quadraticCurveTo( - prevPoint[0], - prevPoint[1], - controlPoint[0], - controlPoint[1], - ); + ctx.quadraticCurveTo( + prevPoint[0], + prevPoint[1], + controlPoint[0], + controlPoint[1], + ); - ctx.lineWidth = ((1 - pointIndexRatio) * LASER.WIDTH) / stageScale; + ctx.lineWidth = ((1 - indexRatio) * LASER.WIDTH) / stageScale; - ctx.stroke(); - } + ctx.stroke(); + } + + ctx.fillStrokeShape(shape); + }, + [path, stageScale], + ); - ctx.fillStrokeShape(shape); - }} + return ( + ); }; diff --git a/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx b/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx index 9c6cebd..95e99d0 100644 --- a/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx +++ b/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx @@ -28,7 +28,6 @@ const LibraryDrawer = ({ items }: Props) => { const stageConfig = useAppSelector(selectConfig); const themeColors = useThemeColors(); - const selectedItemsCount = Object.keys(selectedItemsIds).length; const hasItems = Boolean(items.length); const hasSelectedItems = Boolean(selectedItemsCount); @@ -67,7 +66,13 @@ const LibraryDrawer = ({ items }: Props) => { return ( - + Library @@ -93,7 +98,7 @@ const LibraryDrawer = ({ items }: Props) => { disabled={!hasSelectedItems} onClick={handleRemoveSelectedItems} squared - data-testid="remove-selected-items-button" + data-testid="remove-library-items-button" > diff --git a/apps/client/src/types/global.d.ts b/apps/client/src/types/global.d.ts new file mode 100644 index 0000000..7a9b26b --- /dev/null +++ b/apps/client/src/types/global.d.ts @@ -0,0 +1,8 @@ +import type Konva from 'konva'; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/naming-convention + Konva: typeof Konva; + } +} diff --git a/apps/client/src/utils/draw.ts b/apps/client/src/utils/draw.ts index 8c9d799..510d55b 100644 --- a/apps/client/src/utils/draw.ts +++ b/apps/client/src/utils/draw.ts @@ -36,13 +36,3 @@ export function drawRectangle(points: [Point, Point]): IRect { height: p2[1] - p1[1], }; } - -export function calculateCurveControlPoint( - prevPoint: Point, - currentPoint: Point, -): Point { - return [ - (prevPoint[0] + currentPoint[0]) / 2, - (prevPoint[1] + currentPoint[1]) / 2, - ]; -}