diff --git a/UItests/test.spec.ts b/UItests/test.spec.ts index 97a16b3..a74f905 100644 --- a/UItests/test.spec.ts +++ b/UItests/test.spec.ts @@ -127,6 +127,72 @@ test("Create curved line", async ({ page }) => { await checkNumberOfElementsInLocalStorage(page, 0) }) +test("Zoom", async ({ page }) => { + await page.goto('http://localhost:5173/'); + // zoom by clicking zoomBar + const zoomDownBtn = page.locator('[id=zoomdown]') + const zoomUpBtn = page.locator('[id=zoomup]') + const zoomReset = page.locator('[id=zoomreset]') + await zoomDownBtn.click() + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "width", value: 2112 }) //1920px+192px(10%) + await zoomUpBtn.click() + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "percentage", value: 100 }) + await page.pause() + // zoom by keyPress ctrl/meta + "+"/"-" + await page.locator('#canvas').press('Control++') + await page.keyboard.up("+") + await page.keyboard.up("Control") + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "height", value: 972 }) //1080px - 108px(10%) + await page.locator('#canvas').press('Meta+-') + await page.keyboard.up("-") + await page.keyboard.up("Meta") + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "percentage", value: 100 }) + // zoom by keyPress + wheel scroll + await page.mouse.move(700, 400); + await page.keyboard.down('Control') + await page.mouse.wheel(0, 500); + await page.keyboard.up("Control") + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "percentage", value: 90 }) + // reset zoom (centered) + await zoomReset.click() + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "percentage", value: 100 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "width", value: 1920 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "height", value: 1080 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "x", value: 0 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "y", value: 0 }) +}) + +test("Grab canvas", async ({ page }) => { + await page.goto('http://localhost:5173/'); + const zoomReset = page.locator('[id=zoomreset]') + const toolbarGrab = page.locator('header > [id=canvasGrabBtn]') + await toolbarGrab.click() + // await page.locator('#canvas').click() // it's need if testing in --headed mode + await page.mouse.move(600, 600); + await page.mouse.down(); + await page.mouse.move(700, 700); + await page.mouse.up(); + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "x", value: -1 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "y", value: -1 }) + // reset grab (centered) + await zoomReset.click() + // await page.locator('#canvas').click() // it's need if testing in --headed mode + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "percentage", value: 100 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "width", value: 1920 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "height", value: 1080 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "x", value: 0 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "y", value: 0 }) + // grab by keyPress + mouse move + await page.mouse.move(600, 600); + await page.keyboard.down('Control') + await page.mouse.down(); + await page.mouse.move(400, 400); + await page.mouse.up(); + await page.keyboard.up("Control") + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "x", value: 1 }) + await checkCanvasViewBoxParametersInLocalStorage(page, { key: "y", value: 1 }) +}) + async function checkElementInLocalStorage(page: Page, elementType: string) { return await page.waitForFunction(type => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -141,6 +207,22 @@ async function checkNumberOfElementsInLocalStorage(page: Page, expected: number) }, expected); } +async function checkCanvasViewBoxParametersInLocalStorage(page: Page, param: { key: string; value: number }) { + return await page.waitForFunction(param => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (param.key === "x" || param.key === "y") { + if (param.value <= 0) { + return JSON.parse(localStorage['canvasViewBox'])[param.key] <= param.value; + } + if (param.value >= 0) { + return JSON.parse(localStorage['canvasViewBox'])[param.key] >= param.value; + } + } else { + return JSON.parse(localStorage['canvasViewBox'])[param.key] === param.value; + } + }, param); +} + // let context; diff --git a/src/App.tsx b/src/App.tsx index 77dc439..2ff218b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,35 @@ +import { useAtom } from 'jotai'; import Canvas from './components/Canvas'; import Inspector from './components/Inspector'; import Layers from './components/Layers'; import Toolbar from './components/Toolbar'; +import Zoom from './components/Zoom'; +import { onKeyPressAtom } from './store/store'; const App = () => { + const [, onKeyPress] = useAtom(onKeyPressAtom); + return ( -
+
{ + if (e.key === '+' || e.key === '-') { + e.preventDefault(); + } + onKeyPress({ ctrlKey: e.ctrlKey || e.metaKey, key: e.key }); + }} + onKeyUp={(e) => { + if (e.key === '+' || e.key === '-') { + e.preventDefault(); + } + onKeyPress({ ctrlKey: e.ctrlKey || e.metaKey, key: '' }); + }} + > +
); }; diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 8cb33f9..67fda7c 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import SingleElement from './SingleElement'; import SelectingArea from './SelectingArea'; @@ -9,10 +9,14 @@ import { onMouseMoveAtom, isDraggingAtom, isDrawingAtom, - onKeyPressAtom, - initialElementAtom, + keyPressedAtom, + canvasViewBoxAtom, + zoomCanvasAtom, + creationInitialElementAtom, + selectingAreaAtom, + grabCanvasAtom, } from '../store/store'; -import { ElemenEvent } from '../types/CommonTypes'; +import { ElemenEvent, ZoomCanvasFn } from '../types/CommonTypes'; import { transformCoordinates } from '../assets/utilities'; const Canvas = () => { @@ -22,8 +26,12 @@ const Canvas = () => { const [, onMouseUp] = useAtom(onMouseUpAtom); const [, onMouseDown] = useAtom(onMouseDownAtom); const [, onMouseMove] = useAtom(onMouseMoveAtom); - const [, onKeyPress] = useAtom(onKeyPressAtom); - const initialElement = useAtomValue(initialElementAtom); + const keyPressed = useAtomValue(keyPressedAtom); + const canvasViewBox = useAtomValue(canvasViewBoxAtom); + const [, zoomCanvas] = useAtom(zoomCanvasAtom); + const [, grabCanvas] = useAtom(grabCanvasAtom); + const creationInitialElement = useAtomValue(creationInitialElementAtom); + const selectingArea = useAtomValue(selectingAreaAtom); const svgContainerRef = useRef(null); @@ -51,16 +59,44 @@ const Canvas = () => { }); }; + // for prevent default browser zoom + useEffect(() => { + const handleOnWheel = (e: WheelEvent) => { + e.preventDefault(); + if (keyPressed.ctrlKey && e.deltaY < 0) { + zoomCanvas(ZoomCanvasFn.ZOOMUP); + } + if (keyPressed.ctrlKey && e.deltaY > 0) { + zoomCanvas(ZoomCanvasFn.ZOOMDOWN); + } + if (!keyPressed.ctrlKey) { + grabCanvas({ x: 0, y: e.deltaY }); + } + }; + + const containerElement = svgContainerRef.current; + + if (containerElement) { + containerElement.addEventListener('wheel', handleOnWheel, { + passive: false, + }); + + return () => { + containerElement.removeEventListener('wheel', handleOnWheel); + }; + } + }, [keyPressed.ctrlKey, zoomCanvas, grabCanvas]); + return ( handleMouseDown(e)} onMouseMove={(e) => handleMouseMove(e)} onMouseUp={onMouseUp} - onKeyDown={(e) => onKeyPress(e.key)} preserveAspectRatio="xMinYMin meet" //for the SVG container to be on the entire screen, while the elements inside kept the proportions and x=0, y=0 viewBox started from the upper left corner - viewBox="0 0 1920 1080" + viewBox={`${canvasViewBox.x} ${canvasViewBox.y} ${canvasViewBox.width} ${canvasViewBox.height}`} width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" @@ -87,12 +123,14 @@ const Canvas = () => { /> ))} - {!isDragging && !isDrawing && } + {!isDragging && + !isDrawing && + creationInitialElement.type_name !== 'grab' && } - {initialElement.type_name === 'image' && ( + {creationInitialElement.type_name === 'image' && ( )} diff --git a/src/components/EllipseIconBtn.tsx b/src/components/EllipseIconBtn.tsx index 2278825..ed0aed1 100644 --- a/src/components/EllipseIconBtn.tsx +++ b/src/components/EllipseIconBtn.tsx @@ -8,7 +8,12 @@ interface Props { const EllipseIconBtn = ({ className, handlerClick }: Props) => { return (
- - handleImageChange(e)} - id="image" - type="file" - className="hidden" - /> -
+ + handleImageChange(e)} + id="image" + type="file" + className="hidden" + /> { const [, deleteElements] = useAtom(deleteElementsAtom); const [, updateElements] = useAtom(updateElementsAtom); const [selectedElement] = useAtom(selectedElementAtom); - const [initialElement, setInitialElement] = useAtom(initialElementAtom); + const [initialElement, setInitialElement] = useAtom( + creationInitialElementAtom, + ); const element = useMemo(() => { if (selectedElement !== null) { diff --git a/src/components/LineArrowIconBtn.tsx b/src/components/LineArrowIconBtn.tsx index b76b8ce..d412f10 100644 --- a/src/components/LineArrowIconBtn.tsx +++ b/src/components/LineArrowIconBtn.tsx @@ -11,7 +11,12 @@ const LineArrowIconBtn = ({ className, handlerClick }: Props) => { className={`${className}`} onClick={() => handlerClick('arrow_line')} > - + { id="line" viewBox="0 0 24 24" height="100%" + width="100%" xmlns="http://www.w3.org/2000/svg" > { id="pencil" viewBox="0 0 24 24" height="100%" + width="100%" xmlns="http://www.w3.org/2000/svg" > { return (
+
+ + + + + +
+
+ ); +}; + +export default Zoom; diff --git a/src/store/store.ts b/src/store/store.ts index 3c9059d..6b2ead1 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import { Area, Coordinates, Element } from "../types/CommonTypes"; +import { Area, CanvasViewBox, Coordinates, Element, ZoomCanvasFn } from "../types/CommonTypes"; import { atomWithStorage } from 'jotai/utils' import { getPencilPointsArrFromString, getTrianglePointsArrFromString, useUpdateXYAndDistance } from "../assets/utilities"; @@ -26,17 +26,108 @@ const initialElement: Element = { markerEnd: "", stroke: 'black', strokeWidth: 4, - fill: 'transparent', + fill: 'none', fontSize: "28px", } +const initialCanvasViewBox = { + x: 0, + y: 0, + percentage: 100, + width: 1920, + height: 1080, +} + +export const creationInitialElementAtom = atom(initialElement) -export const initialElementAtom = atom(initialElement) export const elementsAtom = atomWithStorage("elements", []) + export const selectedElementAtom = atom(null) + export const selectingAreaAtom = atom(null) + export const isDraggingAtom = atom(false) + export const isDrawingAtom = atom( - (get) => get(initialElementAtom).type === "free" ? false : true + (get) => get(creationInitialElementAtom).type_name === "free" || + get(creationInitialElementAtom).type_name === "grab" ? + false : + true +) + +export const keyPressedAtom = atom<{ ctrlKey: boolean; key: string }>({ ctrlKey: false, key: "" }) + +export const onKeyPressAtom = atom( + null, + (get, set, key: { ctrlKey: boolean; key: string }) => { + // console.info(key) + set(keyPressedAtom, key) + const creationInitialElement = get(creationInitialElementAtom) + + switch (true) { + case key.key === "Delete": + set(deleteElementsAtom) + break; + case (key.key === "Escape"): + set(creationInitialElementAtom, initialElement) + break; + case (key.key === "+" && key.ctrlKey): + set(zoomCanvasAtom, ZoomCanvasFn.ZOOMUP) + break; + case (key.key === "-" && key.ctrlKey): + set(zoomCanvasAtom, ZoomCanvasFn.ZOOMDOWN) + break; + case (!key.ctrlKey && creationInitialElement.type_name === "grab"): + set(creationInitialElementAtom, initialElement) + break; + } + } +) + +export const canvasViewBoxAtom = atomWithStorage("canvasViewBox", initialCanvasViewBox) + +export const zoomCanvasAtom = atom( + null, + (get, set, updateFn: ZoomCanvasFn) => { + const canvasViewBox = get(canvasViewBoxAtom) + + switch (updateFn) { + case ZoomCanvasFn.ZOOMDOWN: + if (canvasViewBox.percentage === 0) return; + set(canvasViewBoxAtom, { + ...canvasViewBox, + percentage: canvasViewBox.percentage - 10, + width: canvasViewBox.width + 1920 * 0.1, + height: canvasViewBox.height + 1080 * 0.1, + }) + break; + + case ZoomCanvasFn.ZOOMUP: + if (canvasViewBox.percentage === 200) return; + set(canvasViewBoxAtom, { + ...canvasViewBox, + percentage: canvasViewBox.percentage + 10, + width: canvasViewBox.width - 1920 * 0.1, + height: canvasViewBox.height - 1080 * 0.1, + }) + break; + + case ZoomCanvasFn.ZOOMRESET: + set(canvasViewBoxAtom, initialCanvasViewBox) + break; + } + } +) + +export const grabCanvasAtom = atom( + null, + (get, set, update: Coordinates) => { + const canvasViewBox = get(canvasViewBoxAtom) + set(canvasViewBoxAtom, { + ...canvasViewBox, + x: canvasViewBox.x - update.x, + y: canvasViewBox.y - update.y + }) + } ) export const updateElementsAtom = atom( @@ -80,7 +171,7 @@ export const onMouseDownAtom = atom( }) if (get(isDrawingAtom)) { const newEl = { - ...get(initialElementAtom), + ...get(creationInitialElementAtom), id: crypto.randomUUID(), x: update.x, y: update.y, @@ -96,10 +187,18 @@ export const onMouseDownAtom = atom( } set(elementsAtom, (prev) => [...prev, newEl]) set(selectedElementAtom, newEl) - set(initialElementAtom, (prev) => { + set(creationInitialElementAtom, (prev) => { return { ...prev, href: "" } }) } + + if (get(keyPressedAtom).ctrlKey) { + set(creationInitialElementAtom, { + ...get(creationInitialElementAtom), + type: "grab", + type_name: "grab", + }) + } } ) @@ -192,11 +291,17 @@ export const onMouseMoveAtom = atom( d: selectedElement.d + ` L ${update.x} ${update.y}` }) } + + // on move entire canvas + if (get(creationInitialElementAtom).type_name === "grab") { + set(grabCanvasAtom, { x: update.x - selectingArea.startX, y: update.y - selectingArea.startY }) + } + set(selectingAreaAtom, { ...selectingArea, endX: update.x, endY: update.y }) } // this is necessary to display the selected image and to move this image following the cursor - set(initialElementAtom, (prev) => { + set(creationInitialElementAtom, (prev) => { return { ...prev, x: update.x, @@ -210,29 +315,18 @@ export const onMouseUpAtom = atom( null, (get, set) => { // console.log("onMouseUpAtom") + const creationInitialElement = get(creationInitialElementAtom) const selectedElement = get(selectedElementAtom) if (selectedElement) { set(selectedElementAtom, get(elementsAtom).find(el => el.id === selectedElement.id) || selectedElement) } set(isDraggingAtom, false) set(selectingAreaAtom, null) - // if we draw curved-line we don't need to set "free" type? it's need to continue to draw new curved-line - if (selectedElement?.type_name !== "pencil") { - set(initialElementAtom, initialElement) - } - } -) -export const onKeyPressAtom = atom( - null, - (_get, set, key: string) => { - switch (true) { - case key === "Delete": - set(deleteElementsAtom) - break; - case (key === "Escape"): - set(initialElementAtom, initialElement) - break; + const isNeedtoResetCreatinType = creationInitialElement.type_name === "pencil" || creationInitialElement.type_name === "grab" + if (!isNeedtoResetCreatinType) { + set(creationInitialElementAtom, initialElement) } } ) + diff --git a/src/types/CommonTypes.ts b/src/types/CommonTypes.ts index a58d4e8..313002b 100644 --- a/src/types/CommonTypes.ts +++ b/src/types/CommonTypes.ts @@ -1,7 +1,7 @@ export interface Element { id: string; type_name: ElementsTypeName; - type: "free" | "rect" | "ellipse" | "line" | "polygon" | "foreignObject" | "path" | "image"; + type: "grab" | "free" | "rect" | "ellipse" | "line" | "polygon" | "foreignObject" | "path" | "image"; x: number; y: number; width: number; @@ -46,8 +46,10 @@ export type ElemenEvent = | React.MouseEvent | React.MouseEvent | React.MouseEvent + | React.MouseEvent export const ELEMENT_TYPE_VARIANTS = { + grab: "grab", free: 'free', rect: 'rect', ellipse: 'ellipse', @@ -60,3 +62,17 @@ export const ELEMENT_TYPE_VARIANTS = { }; export type ElementsTypeName = keyof typeof ELEMENT_TYPE_VARIANTS; + +export type CanvasViewBox = { + x: number, + y: number, + percentage: number, + width: number, + height: number +} + +export enum ZoomCanvasFn { + ZOOMUP, + ZOOMDOWN, + ZOOMRESET, +}