diff --git a/src/common/components/icons/index.ts b/src/common/components/icons/index.ts index 2bf628b8..c5a2d422 100644 --- a/src/common/components/icons/index.ts +++ b/src/common/components/icons/index.ts @@ -8,3 +8,5 @@ export * from './x-icon.component'; export * from './quickmock-logo.component'; export * from './copy-icon.component'; export * from './paste-icon.component'; +export * from './delete-icon.component'; +export * from './pencil-icon.component'; diff --git a/src/common/components/icons/pencil-icon.component.tsx b/src/common/components/icons/pencil-icon.component.tsx new file mode 100644 index 00000000..232a6868 --- /dev/null +++ b/src/common/components/icons/pencil-icon.component.tsx @@ -0,0 +1,15 @@ +export const PencilIcon = () => { + return ( + + + + ); +}; diff --git a/src/core/providers/canvas/canvas.model.ts b/src/core/providers/canvas/canvas.model.ts index 3bad17c5..7143f211 100644 --- a/src/core/providers/canvas/canvas.model.ts +++ b/src/core/providers/canvas/canvas.model.ts @@ -96,5 +96,11 @@ export interface CanvasContextModel { setFileName: (fileName: string) => void; fullDocument: DocumentModel; addNewPage: () => void; + duplicatePage: (pageIndex: number) => void; setActivePage: (pageId: string) => void; + deletePage: (pageIndex: number) => void; + isThumbnailContextMenuVisible: boolean; + setIsThumbnailContextMenuVisible: React.Dispatch< + React.SetStateAction + >; } diff --git a/src/core/providers/canvas/canvas.provider.tsx b/src/core/providers/canvas/canvas.provider.tsx index 4f3a51a0..b9910739 100644 --- a/src/core/providers/canvas/canvas.provider.tsx +++ b/src/core/providers/canvas/canvas.provider.tsx @@ -23,6 +23,8 @@ export const CanvasProvider: React.FC = props => { const stageRef = React.useRef(null); const [isInlineEditing, setIsInlineEditing] = React.useState(false); const [fileName, setFileName] = React.useState(''); + const [isThumbnailContextMenuVisible, setIsThumbnailContextMenuVisible] = + React.useState(false); const { addSnapshot, @@ -53,15 +55,55 @@ export const CanvasProvider: React.FC = props => { ); }; + const duplicatePage = (pageIndex: number) => { + const newShapes: ShapeModel[] = document.pages[pageIndex].shapes.map( + shape => { + const newShape: ShapeModel = { ...shape }; + newShape.id = uuidv4(); + return newShape; + } + ); + + setDocument(lastDocument => + produce(lastDocument, draft => { + const newPage = { + id: uuidv4(), + name: `Page ${draft.pages.length + 1}`, + shapes: newShapes, + }; + draft.pages.push(newPage); + setActivePage(newPage.id); + }) + ); + }; + + const deletePage = (pageIndex: number) => { + const newActivePageId = + pageIndex < document.pages.length - 1 + ? document.pages[pageIndex + 1].id // If it's not the last page, select the next one + : document.pages[pageIndex - 1].id; // Otherwise, select the previous one + + setDocument(lastDocument => + produce(lastDocument, draft => { + draft.pages = draft.pages.filter( + currentPage => document.pages[pageIndex].id !== currentPage.id + ); + }) + ); + + setActivePage(newActivePageId); + }; + const setActivePage = (pageId: string) => { selectionInfo.clearSelection(); selectionInfo.shapeRefs.current = {}; + setDocument(lastDocument => produce(lastDocument, draft => { - draft.activePageIndex = draft.pages.findIndex( - page => page.id === pageId - ); - console.log(draft.activePageIndex); + const pageIndex = draft.pages.findIndex(page => page.id === pageId); + if (pageIndex !== -1) { + draft.activePageIndex = pageIndex; + } }) ); }; @@ -245,7 +287,11 @@ export const CanvasProvider: React.FC = props => { setFileName, fullDocument: document, addNewPage, + duplicatePage, setActivePage, + deletePage, + isThumbnailContextMenuVisible, + setIsThumbnailContextMenuVisible, }} > {children} diff --git a/src/pods/canvas/use-multiple-selection-shape.hook.tsx b/src/pods/canvas/use-multiple-selection-shape.hook.tsx index 676e1083..698c265c 100644 --- a/src/pods/canvas/use-multiple-selection-shape.hook.tsx +++ b/src/pods/canvas/use-multiple-selection-shape.hook.tsx @@ -13,6 +13,7 @@ import { calculateScaledCoordsFromCanvasDivCoordinatesNoScroll } from './canvas. import { Stage } from 'konva/lib/Stage'; import { isUserDoingMultipleSelectionUsingCtrlOrCmdKey } from '@/common/utils/shapes'; import { KonvaEventObject } from 'konva/lib/Node'; +import { useCanvasContext } from '@/core/providers'; // There's a bug here: if you make a multiple selectin and start dragging // inside the selection but on a blank area it won't drag the selection @@ -49,6 +50,8 @@ export const useMultipleSelectionShapeHook = ( visible: false, }); + const { setIsThumbnailContextMenuVisible } = useCanvasContext(); + const isDraggingSelection = (mouseCoords: Coord) => { if (!transformerRef.current) { return false; @@ -166,6 +169,8 @@ export const useMultipleSelectionShapeHook = ( height: 0, visible: true, }); + + setIsThumbnailContextMenuVisible(false); }; const handleMouseMove = (e: any) => { diff --git a/src/pods/context-menu/use-context-menu.hook.tsx b/src/pods/context-menu/use-context-menu.hook.tsx index d2c53fbb..3b8e1177 100644 --- a/src/pods/context-menu/use-context-menu.hook.tsx +++ b/src/pods/context-menu/use-context-menu.hook.tsx @@ -26,7 +26,11 @@ export const ContextMenu: React.FC = ({ dropRef }) => { const handleRightClick = (event: MouseEvent) => { event.preventDefault(); - if (selectionInfo.getSelectedShapeData()) { + if ( + selectionInfo.getSelectedShapeData() && + stageRef.current && + stageRef.current.container().contains(event.target as Node) + ) { setShowContextMenu(true); setContextMenuPosition({ x: event.clientX, y: event.clientY }); } diff --git a/src/pods/thumb-pages/components/context-menu/context-menu.component.module.css b/src/pods/thumb-pages/components/context-menu/context-menu.component.module.css new file mode 100644 index 00000000..3493bb7b --- /dev/null +++ b/src/pods/thumb-pages/components/context-menu/context-menu.component.module.css @@ -0,0 +1,40 @@ +.context-menu { + position: absolute; + top: 50%; + left: 50%; + width: 80%; + height: auto; + transform: translate(-50%, -50%); + border: 1px solid var(--primary-500); + background-color: var(--primary-100); + opacity: 0.9; +} + +.container { + display: flex; + gap: 0.5em; + align-items: center; + font-size: var(--fs-xs); + padding: var(--space-xs) var(--space-md); + border-bottom: 1px solid var(--primary-300); + cursor: pointer; +} + +.container :first-child { + flex: 1; +} + +.container:hover { + background-color: var(--primary-200); +} + +.disabled { + cursor: not-allowed; + opacity: 0.5; + background-color: var(--primary-200); +} + +.shortcut { + color: var(--primary-400); + font-weight: 500; +} diff --git a/src/pods/thumb-pages/components/context-menu/context-menu.component.tsx b/src/pods/thumb-pages/components/context-menu/context-menu.component.tsx new file mode 100644 index 00000000..b70d8052 --- /dev/null +++ b/src/pods/thumb-pages/components/context-menu/context-menu.component.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { useCanvasContext } from '@/core/providers'; +import classes from './context-menu.component.module.css'; +import { CopyIcon, DeleteIcon } from '@/common/components/icons'; + +interface ThumbPageContextMenuProps { + contextMenuRef: React.RefObject; + setShowContextMenu: (show: boolean) => void; + pageIndex: number; +} + +export const ThumbPageContextMenu: React.FunctionComponent< + ThumbPageContextMenuProps +> = props => { + const { contextMenuRef, setShowContextMenu, pageIndex } = props; + const { + setIsThumbnailContextMenuVisible, + fullDocument, + duplicatePage, + deletePage, + } = useCanvasContext(); + + enum ContextButtonType { + 'Duplicate', + 'Rename', + 'Delete', + } + + const handleClickOnContextButton = ( + event: React.MouseEvent, + buttonClicked: ContextButtonType + ) => { + event.stopPropagation(); + switch (buttonClicked) { + case ContextButtonType.Duplicate: + duplicatePage(pageIndex); + break; + case ContextButtonType.Rename: + console.log('Rename'); + break; + case ContextButtonType.Delete: + deletePage(pageIndex); + break; + } + setShowContextMenu(false); + setIsThumbnailContextMenuVisible(false); + }; + + return ( +
+
+ handleClickOnContextButton(event, ContextButtonType.Duplicate) + } + className={classes.container} + > +

Duplicate

+ +
+
+ handleClickOnContextButton(event, ContextButtonType.Delete) + } + className={ + fullDocument.pages.length === 1 + ? `${classes.container} ${classes.disabled}` + : `${classes.container}` + } + > +

Delete

+ +
+
+ ); +}; diff --git a/src/pods/thumb-pages/components/thumb-page.tsx b/src/pods/thumb-pages/components/thumb-page.tsx index f245fe39..fae5a419 100644 --- a/src/pods/thumb-pages/components/thumb-page.tsx +++ b/src/pods/thumb-pages/components/thumb-page.tsx @@ -5,6 +5,8 @@ import { calculateCanvasBounds } from '@/pods/toolbar/components/export-button/e import { KonvaEventObject } from 'konva/lib/Node'; import { createRef, useRef } from 'react'; import { Layer, Stage } from 'react-konva'; +import { ThumbPageContextMenu } from './context-menu/context-menu.component'; +import { useContextMenu } from '../use-context-menu-thumb.hook'; interface Props { pageIndex: number; @@ -13,15 +15,12 @@ interface Props { export const ThumbPage: React.FunctionComponent = props => { const { pageIndex, onSetActivePage } = props; - const { fullDocument } = useCanvasContext(); - const page = fullDocument.pages[pageIndex]; const shapes = page.shapes; const fakeShapeRefs = useRef({}); const bounds = calculateCanvasBounds(shapes); - const canvasSize = { width: bounds.x + bounds.width, height: bounds.y + bounds.height, @@ -30,27 +29,49 @@ export const ThumbPage: React.FunctionComponent = props => { const scaleFactorY = 180 / canvasSize.height; const finalScale = Math.min(scaleFactorX, scaleFactorY); + const { + showContextMenu, + contextMenuRef, + setShowContextMenu, + handleShowContextMenu, + } = useContextMenu(); + return ( -
onSetActivePage(page.id)} - > - - - {shapes.map(shape => { - if (!fakeShapeRefs.current[shape.id]) { - fakeShapeRefs.current[shape.id] = createRef(); - } - return renderShapeComponent(shape, { - handleSelected: () => {}, - shapeRefs: fakeShapeRefs, - handleDragEnd: - (_: string) => (_: KonvaEventObject) => {}, - handleTransform: () => {}, - }); - })} - - -
+ <> +
onSetActivePage(page.id)} + onContextMenu={handleShowContextMenu} + > + + + {shapes.map(shape => { + if (!fakeShapeRefs.current[shape.id]) { + fakeShapeRefs.current[shape.id] = createRef(); + } + return renderShapeComponent(shape, { + handleSelected: () => {}, + shapeRefs: fakeShapeRefs, + handleDragEnd: + (_: string) => (_: KonvaEventObject) => {}, + handleTransform: () => {}, + }); + })} + + + {showContextMenu && ( + + )} +
+ ); }; diff --git a/src/pods/thumb-pages/use-context-menu-thumb.hook.tsx b/src/pods/thumb-pages/use-context-menu-thumb.hook.tsx new file mode 100644 index 00000000..5d5a65a6 --- /dev/null +++ b/src/pods/thumb-pages/use-context-menu-thumb.hook.tsx @@ -0,0 +1,42 @@ +import { useCanvasContext } from '@/core/providers'; +import { useEffect, useRef, useState } from 'react'; + +export const useContextMenu = () => { + const [showContextMenu, setShowContextMenu] = useState(false); + const contextMenuRef = useRef(null); + const { setIsThumbnailContextMenuVisible } = useCanvasContext(); + + const handleShowContextMenu = ( + event: React.MouseEvent + ) => { + event.preventDefault(); + if (!showContextMenu) { + setIsThumbnailContextMenuVisible(true); + setShowContextMenu(true); + } + }; + + useEffect(() => { + const closeContextMenu = (event: MouseEvent) => { + if ( + contextMenuRef.current && + !contextMenuRef.current.contains(event.target as Node) + ) { + setShowContextMenu(false); + setIsThumbnailContextMenuVisible(false); + } + }; + + window.addEventListener('mousedown', closeContextMenu); + return () => { + window.removeEventListener('mousedown', closeContextMenu); + }; + }, [showContextMenu, setIsThumbnailContextMenuVisible]); + + return { + showContextMenu, + contextMenuRef, + setShowContextMenu, + handleShowContextMenu, + }; +};