564 lines
17 KiB
TypeScript
Raw Normal View History

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[]
users: User[]
metadata: { [key: string]: ProfileMetadata }
onDrawFieldsChange: (pdfFiles: PdfFile[]) => void
}
export const DrawPDFFields = (props: Props) => {
const { selectedFiles } = props
const [pdfFiles, setPdfFiles] = useState<PdfFile[]>([])
const [parsingPdf, setParsingPdf] = useState<boolean>(false)
const [showDrawToolBox, setShowDrawToolBox] = useState<boolean>(false)
const [selectedTool, setSelectedTool] = useState<DrawTool | null>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: MarkType.SIGNATURE,
icon: <Gesture/>,
label: 'Signature',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: <Badge/>,
label: 'Full Name',
active: true
},
{
identifier: MarkType.JOBTITLE,
icon: <Work/>,
label: 'Job Title',
active: false
},
{
identifier: MarkType.DATE,
icon: <CalendarMonth/>,
label: 'Date',
active: false
},
{
identifier: MarkType.DATETIME,
icon: <AccessTime/>,
label: 'Datetime',
active: false
},
])
const [mouseState, setMouseState] = useState<MouseState>({
clicked: false
})
useEffect(() => {
if (selectedFiles) {
setParsingPdf(true)
parsePdfPages().finally(() => {
setParsingPdf(false)
})
}
}, [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])
}
2024-07-12 15:07:36 +02:00
/**
* Fired only when left click and mouse over pdf page
* Creates new drawnElement and pushes in the array
* It is re rendered and visible right away
*
* @param event Mouse event
* @param page PdfPage where press happened
*/
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, rect } = getMouseCoordinates(event)
const newField: DrawnField = {
left: mouseX,
top: mouseY,
bottom: rect.height - mouseY,
width: 0,
height: 0,
counterpart: '',
type: selectedTool.identifier
}
page.drawnFields.push(newField)
setMouseState((prev) => {
return {
...prev,
clicked: true
}
})
}
2024-07-12 15:07:36 +02:00
/**
* Drawing is finished, resets all the variables used to draw
* @param event Mouse event
*/
const onMouseUp = (event: MouseEvent) => {
setMouseState((prev) => {
return {
...prev,
clicked: false,
dragging: false,
resizing: false
}
})
}
2024-07-12 15:07:36 +02:00
/**
* After {@link onMouseDown} create an drawing element, this function gets called on every pixel moved
* which alters the newly created drawing element, resizing it while mouse move
* @param event Mouse event
* @param page PdfPage where moving is happening
*/
const onMouseMove = (event: any, page: PdfPage) => {
if (mouseState.clicked && selectedTool) {
const lastElementIndex = page.drawnFields.length -1
const lastDrawnField = page.drawnFields[lastElementIndex]
const { mouseX, mouseY, rect } = getMouseCoordinates(event)
const width = mouseX - lastDrawnField.left
const height = mouseY - lastDrawnField.top
const bottom = rect.height - height - lastDrawnField.top - 3
lastDrawnField.width = width
lastDrawnField.height = height
lastDrawnField.bottom = bottom
const currentDrawnFields = page.drawnFields
currentDrawnFields[lastElementIndex] = lastDrawnField
refreshPdfFiles()
}
}
2024-07-12 15:07:36 +02:00
/**
* Fired when event happens on the drawn element which will be moved
* mouse coordinates relative to drawn element will be stored
* so when we start moving, offset can be calculated
* mouseX - offsetX
* mouseY - offsetY
*
* @param event Mouse event
* @param drawnField Which we are moving
*/
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
}
})
}
2024-07-12 15:07:36 +02:00
/**
* Moves the drawnElement by the mouse position (mouse can grab anywhere on the drawn element)
* @param event Mouse event
* @param drawnField which we are moving
*/
const onDrawnFieldMouseMove = (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
drawnField.bottom = rect.height - drawnField.height - top - 3
refreshPdfFiles()
}
}
}
2024-07-12 15:07:36 +02:00
/**
* Fired when clicked
drawnField.top = top
on the resize handle, sets the state for a resize action
2024-07-12 15:07:36 +02:00
* @param event Mouse event
* @param drawnField which we are resizing
*/
const onResizeHandleMouseDown = (event: any, drawnField: DrawnField) => {
// Proceed only if left click
if (event.button !== 0) return
event.stopPropagation()
setMouseState({
resizing: true
})
}
2024-07-12 15:07:36 +02:00
/**
* Resizes the drawn element by the mouse position
* @param event Mouse event
* @param drawnField which we are resizing
*/
const onResizeHandleMouseMove = (event: any, drawnField: DrawnField) => {
if (mouseState.resizing) {
const { mouseX, mouseY, rect } = getMouseCoordinates(event, event.target.parentNode.parentNode)
const width = mouseX - drawnField.left
const height = mouseY - drawnField.top
drawnField.width = width
drawnField.height = height
drawnField.bottom = rect.height - height - drawnField.top - 3
refreshPdfFiles()
}
}
2024-07-12 15:07:36 +02:00
/**
* Removes the drawn element using the indexes in the params
* @param event Mouse event
* @param pdfFileIndex pdf file index
* @param pdfPageIndex pdf page index
* @param drawnFileIndex drawn file index
*/
const onRemoveHandleMouseDown = (event: any, pdfFileIndex: number, pdfPageIndex: number, drawnFileIndex: number) => {
event.stopPropagation()
pdfFiles[pdfFileIndex].pages[pdfPageIndex].drawnFields.splice(drawnFileIndex, 1)
}
2024-07-12 15:07:36 +02:00
/**
* Used to stop mouse click propagating to the parent elements
* so select can work properly
* @param event Mouse event
*/
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
}
}
2024-07-12 15:07:36 +02:00
/**
* Reads the pdf binary files and converts it's pages to images
* creates the pdfFiles object and sets to a state
*/
const parsePdfPages = async () => {
const pdfFiles: PdfFile[] = []
2024-06-29 00:44:06 +02:00
for (const file of selectedFiles) {
if (file.type.toLowerCase().includes('pdf')) {
const data = await readPdf(file)
const pages = await pdfToImages(data)
pdfFiles.push({
file: file,
pages: pages,
expanded: false
})
}
}
setPdfFiles(pdfFiles)
}
/**
* Converts pdf to the images
* @param data pdf file bytes
*/
const pdfToImages = async (data: any): Promise<PdfPage[]> => {
const images: string[] = [];
const pdf = await PDFJS.getDocument(data).promise;
const canvas = document.createElement("canvas");
for (let i = 0; i < pdf.numPages; i++) {
const page = await pdf.getPage(i + 1);
const viewport = page.getViewport({ scale: 1 });
const context = canvas.getContext("2d");
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: context!, viewport: viewport }).promise;
images.push(canvas.toDataURL());
}
return Promise.resolve(images.map((image) => {
return {
image,
drawnFields: []
}
}))
}
2024-07-12 15:07:36 +02:00
/**
* Reads the pdf file binaries
*/
const readPdf = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e: any) => {
const data = e.target.result
resolve(data)
};
reader.onerror = (err) => {
console.error('err', err)
reject(err)
};
reader.readAsDataURL(file);
})
}
2024-07-12 15:07:36 +02:00
/**
*
* @returns if expanded pdf accordion is present
*/
const hasExpandedPdf = () => {
return !!pdfFiles.filter(pdfFile => !!pdfFile.expanded).length
}
2024-06-29 00:46:35 +02:00
const handleAccordionExpandChange = (expanded: boolean, pdfFile: PdfFile) => {
pdfFile.expanded = expanded
refreshPdfFiles()
setShowDrawToolBox(hasExpandedPdf())
}
2024-07-12 15:07:36 +02:00
/**
* Changes the drawing tool
* @param drawTool to draw with
*/
const handleToolSelect = (drawTool: DrawTool) => {
// If clicked on the same tool, unselect
if (drawTool.identifier === selectedTool?.identifier) {
setSelectedTool(null)
return
}
setSelectedTool(drawTool)
}
2024-07-12 15:07:36 +02:00
/**
* Renders the pdf pages and drawing elements
*/
const getPdfPages = (pdfFile: PdfFile, pdfFileIndex: number) => {
return (
<Box sx={{
width: '100%'
}}>
{pdfFile.pages.map((page, pdfPageIndex: number) => {
return (
<div
key={pdfPageIndex}
style={{
border: '1px solid #c4c4c4',
marginBottom: '10px'
}}
className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
onMouseMove={(event) => {onMouseMove(event, page)}}
onMouseDown={(event) => {onMouseDown(event, page)}}
>
<img draggable="false" style={{ width: '100%' }} src={page.image}/>
{page.drawnFields.map((drawnField, drawnFieldIndex: number) => {
return (
<div
key={drawnFieldIndex}
onMouseDown={(event) => { onDrawnFieldMouseDown(event, drawnField) }}
onMouseMove={(event) => { onDrawnFieldMouseMove(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'
}}
>
<span
onMouseDown={(event) => {onResizeHandleMouseDown(event, drawnField)}}
onMouseMove={(event) => {onResizeHandleMouseMove(event, drawnField)}}
className={styles.resizeHandle}
></span>
<span
onMouseDown={(event) => {onRemoveHandleMouseDown(event, pdfFileIndex, pdfPageIndex, drawnFieldIndex)}}
className={styles.removeHandle}
>
<Close fontSize='small'/>
</span>
<div
onMouseDown={(event) => {onUserSelectHandleMouseDown(event)}}
className={styles.userSelect}
>
<FormControl fullWidth size='small'>
<InputLabel id="counterparts">Counterpart</InputLabel>
<Select
value={drawnField.counterpart}
onChange={(event) => { drawnField.counterpart = event.target.value; refreshPdfFiles() }}
labelId="counterparts"
label="Counterparts"
>
{props.users.map((user, index) => {
let displayValue = truncate(hexToNpub(user.pubkey), {
length: 16
})
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(metadata.name || metadata.display_name || metadata.username, {
length: 16
})
}
return <MenuItem key={index} value={hexToNpub(user.pubkey)}>{displayValue}</MenuItem>
})}
</Select>
</FormControl>
</div>
</div>
)
})}
</div>
)
})}
</Box>
)
}
if (parsingPdf) {
return (
<Box sx={{ width: '100%', textAlign: 'center' }}>
<CircularProgress/>
</Box>
)
}
if (!pdfFiles.length) {
return ''
}
return (
<Box>
<Box sx={{ mt: 1 }}>
<Typography sx={{ mb: 1 }}>Draw fields on the PDFs:</Typography>
{pdfFiles.map((pdfFile, pdfFileIndex: number) => {
return (
<Accordion key={pdfFileIndex} expanded={pdfFile.expanded} onChange={(_event, expanded) => {handleAccordionExpandChange(expanded, pdfFile)}}>
<AccordionSummary
expandIcon={<ExpandMore />}
aria-controls={`panel${pdfFileIndex}-content`}
id={`panel${pdfFileIndex}header`}
>
<PictureAsPdf sx={{ mr: 1 }}/>
{pdfFile.file.name}
</AccordionSummary>
<AccordionDetails>
{getPdfPages(pdfFile, pdfFileIndex)}
</AccordionDetails>
</Accordion>
)
})}
</Box>
{showDrawToolBox && (
<Box className={styles.drawToolBoxContainer}>
<Box className={styles.drawToolBox}>
{toolbox.filter(drawTool => drawTool.active).map((drawTool: DrawTool, index: number) => {
return (
<Box
key={index}
onClick={() => {handleToolSelect(drawTool)}} className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}>
{ drawTool.icon }
{ drawTool.label }
</Box>
)
})}
</Box>
</Box>
)}
</Box>
)
}