PDF Markings #114

Merged
eugene merged 33 commits from issue-99 into staging 2024-08-06 10:02:04 +00:00
7 changed files with 600 additions and 98 deletions
Showing only changes of commit 8576034829 - Show all commits

View File

@ -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<DrawTool | null>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: 'signature',
identifier: MarkType.SIGNATURE,
icon: <Gesture/>,
label: 'Signature'
label: 'Signature',
active: false
},
{
identifier: 'fullname',
identifier: MarkType.FULLNAME,
icon: <Badge/>,
label: 'Full Name'
label: 'Full Name',
active: true
},
{
identifier: 'jobtitle',
identifier: MarkType.JOBTITLE,
icon: <Work/>,
label: 'Job Title'
label: 'Job Title',
active: false
},
{
identifier: 'date',
identifier: MarkType.DATE,
icon: <CalendarMonth/>,
label: 'Date'
label: 'Date',
active: false
},
{
identifier: 'datetime',
identifier: MarkType.DATETIME,
icon: <AccessTime/>,
label: 'Datetime'
label: 'Datetime',
active: false
},
])
const [mouseState, setMouseState] = useState<MouseState>({
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 (
<Box sx={{
width: '100%'
}}>
{pdfFile.pages.map((page: string) => {
return (
<div style={{
border: '1px solid #c4c4c4',
marginBottom: '10px'
}} className={`${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}>
<img draggable="false" style={{ width: '100%' }} src={page}/>
</div>
)
})}
</Box>
)
}
/**
* Converts pdf to the images
* @param data pdf file bytes
*/
const pdfToImages = async (data: any): Promise<string[]> => {
const pdfToImages = async (data: any): Promise<PdfPage[]> => {
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 (
<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) => { 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'
}}
>
<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' }}>
@ -187,19 +443,19 @@ export const DrawPDFFields = (props: Props) => {
<Box sx={{ mt: 1 }}>
<Typography sx={{ mb: 1 }}>Draw fields on the PDFs:</Typography>
{pdfFiles.map((pdfFile) => {
{pdfFiles.map((pdfFile, pdfFileIndex: number) => {
return (
<Accordion expanded={pdfFile.expanded} onChange={(_event, expanded) => {handleAccordionExpandChange(expanded, pdfFile)}}>
<Accordion key={pdfFileIndex} expanded={pdfFile.expanded} onChange={(_event, expanded) => {handleAccordionExpandChange(expanded, pdfFile)}}>
<AccordionSummary
expandIcon={<ExpandMore />}
aria-controls="panel1-content"
id="panel1-header"
aria-controls={`panel${pdfFileIndex}-content`}
id={`panel${pdfFileIndex}header`}
>
<PictureAsPdf sx={{ mr: 1 }}/>
{pdfFile.file.name}
</AccordionSummary>
<AccordionDetails>
{getPdfPages(pdfFile)}
{getPdfPages(pdfFile, pdfFileIndex)}
</AccordionDetails>
</Accordion>
)
@ -209,9 +465,11 @@ export const DrawPDFFields = (props: Props) => {
{showDrawToolBox && (
<Box className={styles.drawToolBoxContainer}>
<Box className={styles.drawToolBox}>
{toolbox.map((drawTool: DrawTool) => {
{toolbox.filter(drawTool => drawTool.active).map((drawTool: DrawTool, index: number) => {
return (
<Box onClick={() => {handleToolSelect(drawTool)}} className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}>
<Box
key={index}
onClick={() => {handleToolSelect(drawTool)}} className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''}`}>
{ drawTool.icon }
{ drawTool.label }
</Box>

View File

@ -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;
}
}

View File

@ -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<PdfFile[]>([])
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 (
<iframe
@ -639,15 +710,21 @@ export const CreatePage = () => {
</Box>
<DisplayUser
metadata={metadata}
users={users}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
<DrawPDFFields selectedFiles={selectedFiles}/>
<DrawPDFFields
metadata={metadata}
users={users}
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
/>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Box sx={{ mt: 1, mb: 5, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleCreate} variant="contained">
Create
</Button>
@ -658,6 +735,7 @@ export const CreatePage = () => {
}
type DisplayUsersProps = {
metadata: { [key: string]: ProfileMetadata }
users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void
@ -665,51 +743,12 @@ type DisplayUsersProps = {
}
const DisplayUser = ({
metadata,
users,
handleUserRoleChange,
handleRemoveUser,
moveSigner
}: DisplayUsersProps) => {
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
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
)
})
}
})
}, [users])
return (
<TableContainer component={Paper} elevation={3} sx={{ marginTop: '20px' }}>
<Table>

View File

@ -1,3 +1,5 @@
import { MarkConfig } from "./mark"
export enum UserRole {
signer = 'Signer',
viewer = 'Viewer'
@ -19,6 +21,7 @@ export interface CreateSignatureEventContent {
signers: `npub1${string}`[]
viewers: `npub1${string}`[]
fileHashes: { [key: string]: string }
markConfig: MarkConfig
}
export interface SignedEventContent {

49
src/types/drawing.ts Normal file
View File

@ -0,0 +1,49 @@
export interface MouseState {
clicked?: boolean
dragging?: boolean
resizing?: boolean
coordsInWrapper?: {
mouseX: number
mouseY: number
}
}
export interface PdfFile {
file: File,
pages: PdfPage[]
expanded?: boolean
}
export interface PdfPage {
image: string
drawnFields: DrawnField[]
}
export interface DrawnField {
left: number
top: number
width: number
height: number
type: MarkType
/**
* npub of a counter part
*/
counterpart: string
}
export interface DrawTool {
identifier: MarkType
label: string
icon: JSX.Element,
defaultValue?: string
selected?: boolean
active?: boolean
}
export enum MarkType {
SIGNATURE = 'SIGNATURE',
JOBTITLE = 'JOBTITLE',
FULLNAME = 'FULLNAME',
DATE = 'DATE',
DATETIME = 'DATETIME'
}

97
src/types/mark.ts Normal file
View File

@ -0,0 +1,97 @@
import { MarkType } from "./drawing";
export interface Mark {
/**
* @key png (pdf page) file hash
*/
[key: string]: MarkConfigDetails[]
}
export interface MarkConfig {
/**
* @key user npub
*/
[key: string]: User
}
export interface User {
/**
* @key png (pdf page) file hash
*/
[key: string]: MarkConfigDetails[]
}
export interface MarkDetails {
/**
* @key coords in format X:10;Y:50
*/
[key: string]: MarkValue
}
export interface MarkValue {
type: MarkType,
value: string
}
export interface MarkConfigDetails {
markType: MarkType;
/**
* Coordinates in format: X:10;Y:50
*/
markLocation: string;
}
// Creator Meta Object Example
const creatorMetaExample = {
"fileHashes": {
// PDF Hash
"Lorem ipsum dolor sit amet.pdf": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
// PDF Pages (in png format) hashes
// Page 1
"da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/1.png": "hash123png1",
// Page 2
"da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": "hash321png2"
},
"markConfig": {
// Signer npub
'npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy': {
// PDF Page 1 (PNG file hash)
'hash123png1': [
{
markType: "FULLNAME",
markLocation: "X:56;Y:306"
}
],
// PDF Page 2 (PNG file hash)
'hash321png2': [
{
markType: "FULLNAME",
markLocation: "X:76;Y:283.71875"
}
]
}
}
}
// Signer meta example
const signerExample = {
"prevSig": "10de030dd2bfafbbd34969645bd0b3f5e8ab71b3b32091fb29bbea5e272f8a3b7284ef667b6a02e9becc1036450d9fbe5c1c6d146fa91d70e0d8f3cd54d64f17",
"marks": {
// PDF Page 1 (PNG file hash)
'hash123png1': {
// Mark coordinates
"X:56;Y:306": {
type: 'FULLNAME',
value: 'Pera Peric'
}
},
// PDF Page 2 (PNG file hash)
'hash321png2': {
// Mark coordinates
"X:76;Y:283.71875": {
type: 'FULLNAME',
value: 'Pera Peric'
}
}
}
}

View File

@ -1,6 +1,7 @@
export interface ProfileMetadata {
name?: string
display_name?: string
username?: string
picture?: string
banner?: string
about?: string