diff --git a/package-lock.json b/package-lock.json index 6944583b..83bf85a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-konva": "^18.2.10", + "react-konva-utils": "^1.0.6", "tiny-invariant": "^1.3.3", "uuid": "^10.0.0" }, @@ -4548,6 +4549,20 @@ "react-dom": ">=18.0.0" } }, + "node_modules/react-konva-utils": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-konva-utils/-/react-konva-utils-1.0.6.tgz", + "integrity": "sha512-011+jyXwadFDkbIUdlTarKwbqME0ljX1vPeW5oyLQx4rtHdcsDr43tdOdSsMT3XpZyl8clgM9I/eK0lBuBuFHg==", + "dependencies": { + "react-konva": "^18.0.0-0", + "use-image": "^1.1.0" + }, + "peerDependencies": { + "konva": "^8.3.5 || ^9.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, "node_modules/react-reconciler": { "version": "0.29.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", @@ -5248,6 +5263,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-image": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/use-image/-/use-image-1.1.1.tgz", + "integrity": "sha512-n4YO2k8AJG/BcDtxmBx8Aa+47kxY5m335dJiCQA5tTeVU4XdhrhqR6wT0WISRXwdMEOv5CSjqekDZkEMiiWaYQ==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/package.json b/package.json index 6929ad6c..ae592977 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-konva": "^18.2.10", + "react-konva-utils": "^1.0.6", "tiny-invariant": "^1.3.3", "uuid": "^10.0.0" }, diff --git a/src/common/components/front-components/input-shape.tsx b/src/common/components/front-components/input-shape.tsx index f250793b..845dec02 100644 --- a/src/common/components/front-components/input-shape.tsx +++ b/src/common/components/front-components/input-shape.tsx @@ -17,7 +17,7 @@ export const getInputShapeSizeRestrictions = (): ShapeSizeRestrictions => inputShapeRestrictions; export const InputShape = forwardRef( - ({ x, y, width, height, id, onSelected, ...shapeProps }, ref) => { + ({ x, y, width, height, id, onSelected, text, ...shapeProps }, ref) => { const { width: restrictedWidth, height: restrictedHeight } = fitSizeToShapeSizeRestrictions(inputShapeRestrictions, width, height); @@ -46,7 +46,7 @@ export const InputShape = forwardRef( y={20} width={width - 10} height={height - 20} - text="Input text..." + text={text} fontFamily="Comic Sans MS, cursive" fontSize={15} fill="gray" diff --git a/src/common/components/front-components/label-shape.tsx b/src/common/components/front-components/label-shape.tsx index afc1fb9d..00a02d0a 100644 --- a/src/common/components/front-components/label-shape.tsx +++ b/src/common/components/front-components/label-shape.tsx @@ -17,7 +17,7 @@ export const getLabelSizeRestrictions = (): ShapeSizeRestrictions => labelSizeRestrictions; export const LabelShape = forwardRef( - ({ x, y, width, height, id, onSelected, ...shapeProps }, ref) => { + ({ x, y, width, height, id, onSelected, text, ...shapeProps }, ref) => { const { width: restrictedWidth, height: restrictedHeight } = fitSizeToShapeSizeRestrictions(labelSizeRestrictions, width, height); @@ -36,7 +36,7 @@ export const LabelShape = forwardRef( y={0} width={restrictedWidth} height={restrictedHeight} - text="Label" + text={text} fontFamily="Comic Sans MS, cursive" fontSize={15} fill="black" diff --git a/src/common/components/inline-edit/index.ts b/src/common/components/inline-edit/index.ts new file mode 100644 index 00000000..2e6769bd --- /dev/null +++ b/src/common/components/inline-edit/index.ts @@ -0,0 +1 @@ +export * from './inline-edit'; diff --git a/src/common/components/inline-edit/inline-edit.tsx b/src/common/components/inline-edit/inline-edit.tsx new file mode 100644 index 00000000..7bbedbc2 --- /dev/null +++ b/src/common/components/inline-edit/inline-edit.tsx @@ -0,0 +1,103 @@ +import { Coord, Size } from '@/core/model'; +import React, { useEffect, useRef, useState } from 'react'; +import { Group } from 'react-konva'; +import { Html } from 'react-konva-utils'; + +type EditType = 'input' | 'textarea'; + +interface Props { + coords: Coord; + size: Size; + isEditable: boolean; + editType: EditType; + text: string; + scale: number; + onTextSubmit: (text: string) => void; + children: React.ReactNode; +} + +export const EditableComponent: React.FC = props => { + const { coords, size, isEditable, text, onTextSubmit, scale, children } = + props; + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(text); + + const inputRef = useRef(null); + + // handle click outside of the input when editing + useEffect(() => { + if (!isEditable) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsEditing(false); + onTextSubmit(editText); + } + }; + + if (isEditing) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isEditing, editText]); + + const handleDoubleClick = () => { + if (isEditable) { + setIsEditing(true); + } + }; + + // TODO: this can be optimized using React.useCallback, issue #90 + // https://github.com/Lemoncode/quickmock/issues/90 + const calculateTextAreaXPosition = () => { + return `${coords.x * scale}px`; + }; + + const calculateTextAreaYPosition = () => { + return `${coords.y * scale}px`; + }; + + const calculateWidth = () => { + return `${size.width}px`; + }; + + const calculateHeight = () => { + return `${size.height}px`; + }; + + // TODO: Componentize this #91 + // https://github.com/Lemoncode/quickmock/issues/91 + return ( + <> + {children} + {isEditing ? ( + + setEditText(e.target.value)} + /> + + ) : null} + + ); +}; diff --git a/src/core/model/index.ts b/src/core/model/index.ts index 704c6cba..eaad117a 100644 --- a/src/core/model/index.ts +++ b/src/core/model/index.ts @@ -46,4 +46,6 @@ export interface ShapeModel { width: number; height: number; type: ShapeType; + allowsInlineEdition: boolean; + text?: string; } diff --git a/src/core/providers/canvas/canvas.model.ts b/src/core/providers/canvas/canvas.model.ts index 79e036f5..9d75fe03 100644 --- a/src/core/providers/canvas/canvas.model.ts +++ b/src/core/providers/canvas/canvas.model.ts @@ -17,6 +17,7 @@ export interface SelectionInfo { selectedShapeId: string; selectedShapeType: ShapeType | null; setZIndexOnSelected: (action: ZIndexAction) => void; + updateTextOnSelected: (text: string) => void; } export interface CanvasContextModel { diff --git a/src/core/providers/canvas/canvas.provider.tsx b/src/core/providers/canvas/canvas.provider.tsx index 854785dc..2327ceb9 100644 --- a/src/core/providers/canvas/canvas.provider.tsx +++ b/src/core/providers/canvas/canvas.provider.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { ShapeModel } from '@/core/model'; import { CanvasContext } from './canvas.context'; import { useSelection } from './use-selection.hook'; -import { ZIndexAction } from './canvas.model'; interface Props { children: React.ReactNode; diff --git a/src/core/providers/canvas/use-selection.hook.ts b/src/core/providers/canvas/use-selection.hook.ts index c93bfcbf..d0f61b62 100644 --- a/src/core/providers/canvas/use-selection.hook.ts +++ b/src/core/providers/canvas/use-selection.hook.ts @@ -51,6 +51,14 @@ export const useSelection = ( ); }; + const updateTextOnSelected = (text: string) => { + setShapes(prevShapes => + prevShapes.map(shape => + shape.id === selectedShapeId ? { ...shape, text } : shape + ) + ); + }; + return { transformerRef, shapeRefs, @@ -60,5 +68,6 @@ export const useSelection = ( selectedShapeId, selectedShapeType, setZIndexOnSelected, + updateTextOnSelected, }; }; diff --git a/src/pods/canvas/canvas.model.ts b/src/pods/canvas/canvas.model.ts index cd4d65ae..4cdbb8ee 100644 --- a/src/pods/canvas/canvas.model.ts +++ b/src/pods/canvas/canvas.model.ts @@ -87,6 +87,27 @@ const getDefaultSizeFromShape = (shapeType: ShapeType): Size => { } }; +const doesShapeAllowInlineEdition = (shapeType: ShapeType): boolean => { + switch (shapeType) { + case 'input': + case 'label': + return true; + default: + return false; + } +}; + +const generateDefaultTextValue = (shapeType: ShapeType): string | undefined => { + switch (shapeType) { + case 'input': + return ''; + case 'label': + return 'Label'; + default: + return undefined; + } +}; + // TODO: create interfaces to hold Coordination and Size // coordinate: { x: number, y: number } // size: { width: number, height: number } @@ -101,5 +122,7 @@ export const createShape = (coord: Coord, shapeType: ShapeType): ShapeModel => { width, height, type: shapeType, + allowsInlineEdition: doesShapeAllowInlineEdition(shapeType), + text: generateDefaultTextValue(shapeType), }; }; diff --git a/src/pods/canvas/canvas.pod.tsx b/src/pods/canvas/canvas.pod.tsx index 05eedee1..9946309e 100644 --- a/src/pods/canvas/canvas.pod.tsx +++ b/src/pods/canvas/canvas.pod.tsx @@ -7,7 +7,7 @@ import { renderShapeComponent } from './shape-renderer'; import { useDropShape } from './use-drop-shape.hook'; import { useMonitorShape } from './use-monitor-shape.hook'; import classes from './canvas.pod.module.css'; -import { ShapeModel } from '@/core/model'; +import { EditableComponent } from '@/common/components/inline-edit'; export const CanvasPod = () => { const { shapes, setShapes, scale, selectionInfo } = useCanvasContext(); @@ -20,6 +20,7 @@ export const CanvasPod = () => { selectedShapeRef, selectedShapeId, selectedShapeType, + updateTextOnSelected, } = selectionInfo; const { isDraggedOver, dropRef } = useDropShape(); @@ -68,12 +69,25 @@ export const CanvasPod = () => { shapeRefs.current[shape.id] = createRef(); } - return renderShapeComponent(shape, { - handleSelected, - shapeRefs, - handleDragEnd, - handleTransform, - }); + return ( + + {renderShapeComponent(shape, { + handleSelected, + shapeRefs, + handleDragEnd, + handleTransform, + })} + + ); }) } ); }; diff --git a/src/pods/canvas/shape-renderer/simple-component/label.renderer.tsx b/src/pods/canvas/shape-renderer/simple-component/label.renderer.tsx index db7e13aa..64bfcd1f 100644 --- a/src/pods/canvas/shape-renderer/simple-component/label.renderer.tsx +++ b/src/pods/canvas/shape-renderer/simple-component/label.renderer.tsx @@ -23,6 +23,8 @@ export const renderLabel = ( onDragEnd={handleDragEnd(shape.id)} onTransform={handleTransform} onTransformEnd={handleTransform} + isEditable={shape.allowsInlineEdition} + text={shape.text} /> ); };