From 8576034829563d1116f14ca7f0928c55adbabf3c Mon Sep 17 00:00:00 2001 From: Stixx Date: Thu, 11 Jul 2024 16:16:36 +0200 Subject: [PATCH] feat(pdf markings): added drawing component, parsing pdfs and displaying in the UI --- src/components/DrawPDFFields/index.tsx | 370 +++++++++++++++--- .../DrawPDFFields/style.module.scss | 55 +++ src/pages/create/index.tsx | 123 ++++-- src/types/core.ts | 3 + src/types/drawing.ts | 49 +++ src/types/mark.ts | 97 +++++ src/types/profile.ts | 1 + 7 files changed, 600 insertions(+), 98 deletions(-) create mode 100644 src/types/drawing.ts create mode 100644 src/types/mark.ts diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 20e14f8..565f43a 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -1,27 +1,20 @@ -import { AccessTime, CalendarMonth, ExpandMore, Gesture, PictureAsPdf, Badge, Work } from '@mui/icons-material' -import { Box, Typography, Accordion, AccordionDetails, AccordionSummary, CircularProgress } from '@mui/material' +import { AccessTime, CalendarMonth, ExpandMore, Gesture, PictureAsPdf, Badge, Work, Close } from '@mui/icons-material' +import { Box, Typography, Accordion, AccordionDetails, AccordionSummary, CircularProgress, FormControl, InputLabel, MenuItem, Select } from '@mui/material' import styles from './style.module.scss' import { useEffect, useState } from 'react' import * as PDFJS from "pdfjs-dist"; +import { ProfileMetadata, User } from '../../types'; +import { PdfFile, DrawTool, MouseState, PdfPage, DrawnField, MarkType } from '../../types/drawing'; +import { truncate } from 'lodash'; +import { hexToNpub } from '../../utils'; PDFJS.GlobalWorkerOptions.workerSrc = 'node_modules/pdfjs-dist/build/pdf.worker.mjs'; interface Props { selectedFiles: any[] -} - -interface PdfFile { - file: File, - pages: string[] - expanded?: boolean -} - -interface DrawTool { - identifier: 'signature' | 'jobtitle' | 'fullname' | 'date' | 'datetime' - label: string - icon: JSX.Element, - defaultValue?: string - selected?: boolean + users: User[] + metadata: { [key: string]: ProfileMetadata } + onDrawFieldsChange: (pdfFiles: PdfFile[]) => void } export const DrawPDFFields = (props: Props) => { @@ -34,33 +27,42 @@ export const DrawPDFFields = (props: Props) => { const [selectedTool, setSelectedTool] = useState() const [toolbox] = useState([ { - identifier: 'signature', + identifier: MarkType.SIGNATURE, icon: , - label: 'Signature' + label: 'Signature', + active: false }, { - identifier: 'fullname', + identifier: MarkType.FULLNAME, icon: , - label: 'Full Name' + label: 'Full Name', + active: true }, { - identifier: 'jobtitle', + identifier: MarkType.JOBTITLE, icon: , - label: 'Job Title' + label: 'Job Title', + active: false }, { - identifier: 'date', + identifier: MarkType.DATE, icon: , - label: 'Date' + label: 'Date', + active: false }, { - identifier: 'datetime', + identifier: MarkType.DATETIME, icon: , - label: 'Datetime' + label: 'Datetime', + active: false }, ]) + const [mouseState, setMouseState] = useState({ + clicked: false + }) + useEffect(() => { if (selectedFiles) { setParsingPdf(true) @@ -71,6 +73,188 @@ export const DrawPDFFields = (props: Props) => { } }, [selectedFiles]) + useEffect(() => { + if (pdfFiles) props.onDrawFieldsChange(pdfFiles) + }, [pdfFiles]) + + /** + * Drawing events + */ + useEffect(() => { + // window.addEventListener('mousedown', onMouseDown); + window.addEventListener('mouseup', onMouseUp); + + return () => { + // window.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mouseup', onMouseUp); + } + }, []) + + const refreshPdfFiles = () => { + setPdfFiles([...pdfFiles]) + } + + const onMouseDown = (event: any, page: PdfPage) => { + // Proceed only if left click + if (event.button !== 0) return + + // Only allow drawing if mouse is not over other drawn element + const isOverPdfImageWrapper = event.target.tagName === 'IMG' + + if (!selectedTool || !isOverPdfImageWrapper) { + return + } + + const { mouseX, mouseY } = getMouseCoordinates(event) + + const newField: DrawnField = { + left: mouseX, + top: mouseY, + width: 0, + height: 0, + counterpart: '', + type: selectedTool.identifier + } + + page.drawnFields.push(newField) + + setMouseState((prev) => { + return { + ...prev, + clicked: true + } + }) + } + + const onMouseUp = (event: MouseEvent) => { + setMouseState((prev) => { + return { + ...prev, + clicked: false, + dragging: false, + resizing: false + } + }) + } + + const onMouseMove = (event: any, page: PdfPage) => { + if (mouseState.clicked && selectedTool) { + const lastElementIndex = page.drawnFields.length -1 + const lastDrawnField = page.drawnFields[lastElementIndex] + + const { mouseX, mouseY } = getMouseCoordinates(event) + + const width = mouseX - lastDrawnField.left + const height = mouseY - lastDrawnField.top + + lastDrawnField.width = width + lastDrawnField.height = height + + const currentDrawnFields = page.drawnFields + + currentDrawnFields[lastElementIndex] = lastDrawnField + + refreshPdfFiles() + } + } + + const onDrawnFieldMouseDown = (event: any, drawnField: DrawnField) => { + event.stopPropagation() + + // Proceed only if left click + if (event.button !== 0) return + + const drawingRectangleCoords = getMouseCoordinates(event) + + setMouseState({ + dragging: true, + clicked: false, + coordsInWrapper: { + mouseX: drawingRectangleCoords.mouseX, + mouseY: drawingRectangleCoords.mouseY + } + }) + } + + const onDranwFieldMouseMove = (event: any, drawnField: DrawnField) => { + if (mouseState.dragging) { + const { mouseX, mouseY, rect } = getMouseCoordinates(event, event.target.parentNode) + const coordsOffset = mouseState.coordsInWrapper + + if (coordsOffset) { + let left = mouseX - coordsOffset.mouseX + let top = mouseY - coordsOffset.mouseY + + const rightLimit = rect.width - drawnField.width - 3 + const bottomLimit = rect.height - drawnField.height - 3 + + if (left < 0) left = 0 + if (top < 0) top = 0 + if (left > rightLimit) left = rightLimit + if (top > bottomLimit) top = bottomLimit + + drawnField.left = left + drawnField.top = top + + refreshPdfFiles() + } + } + } + + const onResizeHandleMouseDown = (event: any, drawnField: DrawnField) => { + // Proceed only if left click + if (event.button !== 0) return + + event.stopPropagation() + + setMouseState({ + resizing: true + }) + } + + const onResizeHandleMouseMove = (event: any, drawnField: DrawnField) => { + if (mouseState.resizing) { + const { mouseX, mouseY } = getMouseCoordinates(event, event.target.parentNode.parentNode) + + const width = mouseX - drawnField.left + const height = mouseY - drawnField.top + + drawnField.width = width + drawnField.height = height + + refreshPdfFiles() + } + } + + const onRemoveHandleMouseDown = (event: any, pdfFileIndex: number, pdfPageIndex: number, drawnFileIndex: number) => { + event.stopPropagation() + + pdfFiles[pdfFileIndex].pages[pdfPageIndex].drawnFields.splice(drawnFileIndex, 1) + } + + const onUserSelectHandleMouseDown = (event: any) => { + event.stopPropagation() + } + + /** + * Gets the mouse coordinates relative to a element in the `event` param + * @param event MouseEvent + * @param customTarget mouse coordinates relative to this element, if not provided + * event.target will be used + */ + const getMouseCoordinates = (event: any, customTarget?: any) => { + const target = customTarget ? customTarget : event.target + const rect = target.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; //x position within the element. + const mouseY = event.clientY - rect.top; //y position within the element. + + return { + mouseX, + mouseY, + rect + } + } + const parsePdfPages = async () => { const pdfFiles: PdfFile[] = [] @@ -81,7 +265,8 @@ export const DrawPDFFields = (props: Props) => { pdfFiles.push({ file: file, - pages: pages + pages: pages, + expanded: false }) } } @@ -89,30 +274,11 @@ export const DrawPDFFields = (props: Props) => { setPdfFiles(pdfFiles) } - const getPdfPages = (pdfFile: PdfFile) => { - return ( - - {pdfFile.pages.map((page: string) => { - return ( -
- -
- ) - })} -
- ) - } - /** * Converts pdf to the images * @param data pdf file bytes */ - const pdfToImages = async (data: any): Promise => { + const pdfToImages = async (data: any): Promise => { const images: string[] = []; const pdf = await PDFJS.getDocument(data).promise; const canvas = document.createElement("canvas"); @@ -127,7 +293,12 @@ export const DrawPDFFields = (props: Props) => { images.push(canvas.toDataURL()); } - return Promise.resolve(images) + return Promise.resolve(images.map((image) => { + return { + image, + drawnFields: [] + } + })) } const readPdf = (file: File) => { @@ -156,7 +327,7 @@ export const DrawPDFFields = (props: Props) => { const handleAccordionExpandChange = (expanded: boolean, pdfFile: PdfFile) => { pdfFile.expanded = expanded - setPdfFiles(pdfFiles) + refreshPdfFiles() setShowDrawToolBox(hasExpandedPdf()) } @@ -170,6 +341,91 @@ export const DrawPDFFields = (props: Props) => { setSelectedTool(drawTool) } + const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => { + return ( + + {pdfFile.pages.map((page, pdfPageIndex: number) => { + return ( +
{onMouseMove(event, page)}} + onMouseDown={(event) => {onMouseDown(event, page)}} + > + + + {page.drawnFields.map((drawnField, drawnFieldIndex: number) => { + return ( +
{ onDrawnFieldMouseDown(event, drawnField) }} + onMouseMove={(event) => { onDranwFieldMouseMove(event, drawnField)}} + className={styles.drawingRectangle} + style={{ + left: `${drawnField.left}px`, + top: `${drawnField.top}px`, + width: `${drawnField.width}px`, + height: `${drawnField.height}px`, + pointerEvents: mouseState.clicked ? 'none' : 'all' + }} + > + {onResizeHandleMouseDown(event, drawnField)}} + onMouseMove={(event) => {onResizeHandleMouseMove(event, drawnField)}} + className={styles.resizeHandle} + > + {onRemoveHandleMouseDown(event, pdfFileIndex, pdfPageIndex, drawnFieldIndex)}} + className={styles.removeHandle} + > + + +
{onUserSelectHandleMouseDown(event)}} + className={styles.userSelect} + > + + Counterpart + + +
+
+ ) + })} +
+ ) + })} +
+ ) + } + if (parsingPdf) { return ( @@ -187,19 +443,19 @@ export const DrawPDFFields = (props: Props) => { Draw fields on the PDFs: - {pdfFiles.map((pdfFile) => { + {pdfFiles.map((pdfFile, pdfFileIndex: number) => { return ( - {handleAccordionExpandChange(expanded, pdfFile)}}> + {handleAccordionExpandChange(expanded, pdfFile)}}> } - aria-controls="panel1-content" - id="panel1-header" + aria-controls={`panel${pdfFileIndex}-content`} + id={`panel${pdfFileIndex}header`} > {pdfFile.file.name} - {getPdfPages(pdfFile)} + {getPdfPages(pdfFile, pdfFileIndex)} ) @@ -209,9 +465,11 @@ export const DrawPDFFields = (props: Props) => { {showDrawToolBox && ( - {toolbox.map((drawTool: DrawTool) => { + {toolbox.filter(drawTool => drawTool.active).map((drawTool: DrawTool, index: number) => { return ( - {handleToolSelect(drawTool)}} className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}> + {handleToolSelect(drawTool)}} className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}> { drawTool.icon } { drawTool.label } diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index 1e929b4..4793f44 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -48,7 +48,62 @@ } .pdfImageWrapper { + position: relative; + user-select: none; + &.drawing { cursor: crosshair; } +} + +.drawingRectangle { + position: absolute; + border: 1px solid #01aaad; + width: 40px; + height: 40px; + z-index: 50; + background-color: #01aaad4b; + cursor: pointer; + display: flex; + justify-content: center; + + .resizeHandle { + position: absolute; + right: -5px; + bottom: -5px; + width: 10px; + height: 10px; + background-color: #fff; + border: 1px solid rgb(160, 160, 160); + border-radius: 50%; + cursor: pointer; + } + + .removeHandle { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + top: -30px; + width: 20px; + height: 20px; + background-color: #fff; + border: 1px solid rgb(160, 160, 160); + border-radius: 50%; + color: #E74C3C; + font-size: 10px; + cursor: pointer; + } + + .userSelect { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + bottom: -60px; + min-width: 170px; + min-height: 30px; + background: #fff; + padding: 5px 0; + } } \ No newline at end of file diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 8d07935..abbd184 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -52,6 +52,7 @@ import { useDrag, useDrop } from 'react-dnd' import saveAs from 'file-saver' import { Event, kinds } from 'nostr-tools' import { DrawPDFFields } from '../../components/DrawPDFFields' +import { PdfFile } from '../../types/drawing' export const CreatePage = () => { const navigate = useNavigate() @@ -76,6 +77,48 @@ export const CreatePage = () => { const nostrController = NostrController.getInstance() + const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( + {} + ) + + const [drawnPdfs, setDrawnPdfs] = useState([]) + + useEffect(() => { + users.forEach((user) => { + if (!(user.pubkey in metadata)) { + const metadataController = new MetadataController() + + const handleMetadataEvent = (event: Event) => { + const metadataContent = + metadataController.extractProfileMetadataContent(event) + if (metadataContent) + setMetadata((prev) => ({ + ...prev, + [user.pubkey]: metadataContent + })) + } + + metadataController.on(user.pubkey, (kind: number, event: Event) => { + if (kind === kinds.Metadata) { + handleMetadataEvent(event) + } + }) + + metadataController + .findMetadata(user.pubkey) + .then((metadataEvent) => { + if (metadataEvent) handleMetadataEvent(metadataEvent) + }) + .catch((err) => { + console.error( + `error occurred in finding metadata for: ${user.pubkey}`, + err + ) + }) + } + }) + }, []) + useEffect(() => { if (uploadedFile) { setSelectedFiles([uploadedFile]) @@ -297,6 +340,8 @@ export const CreatePage = () => { const signers = users.filter((user) => user.role === UserRole.signer) const viewers = users.filter((user) => user.role === UserRole.viewer) + const markConfig = createMarkConfig(fileHashes) + setLoadingSpinnerDesc('Signing nostr event') const createSignature = await signEventForMetaFile( @@ -321,6 +366,28 @@ export const CreatePage = () => { } } + const createMarkConfig = (fileHashes: { [key: string]: string }) => { + let markConfig: any = {} + + drawnPdfs.forEach(drawnPdf => { + const fileHash = fileHashes[drawnPdf.file.name] + + drawnPdf.pages.forEach((page, pageIndex) => { + page.drawnFields.forEach(drawnField => { + if (!markConfig[drawnField.counterpart]) markConfig[drawnField.counterpart] = {} + if (!markConfig[drawnField.counterpart][fileHash]) markConfig[drawnField.counterpart][fileHash] = [] + + markConfig[drawnField.counterpart][fileHash].push({ + markType: drawnField.type, + markLocation: `P:${pageIndex};X:${drawnField.left};Y:${drawnField.top}` + }) + }) + }) + }) + + return markConfig + } + // Add metadata and file hashes to the zip file const addMetaToZip = async ( zip: JSZip, @@ -555,6 +622,10 @@ export const CreatePage = () => { navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) } + const onDrawFieldsChange = (pdfFiles: PdfFile[]) => { + setDrawnPdfs(pdfFiles) + } + if (authUrl) { return (