chore(git): merge
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 39s

This commit is contained in:
Stixx 2024-12-24 10:58:58 +01:00
commit a2b7423c8b
11 changed files with 544 additions and 353 deletions

13
package-lock.json generated
View File

@ -49,7 +49,8 @@
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4", "signature_pad": "^5.0.4",
"tseep": "1.2.1" "tseep": "1.2.1",
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@ -8591,6 +8592,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/use-immer": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz",
"integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==",
"license": "MIT",
"peerDependencies": {
"immer": ">=8.0.0",
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",

View File

@ -59,7 +59,8 @@
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4", "signature_pad": "^5.0.4",
"tseep": "1.2.1" "tseep": "1.2.1",
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",

View File

@ -8,23 +8,24 @@ import {
Select Select
} from '@mui/material' } from '@mui/material'
import styles from './style.module.scss' import styles from './style.module.scss'
import React, { useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types' import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { MouseState, DrawnField, DrawTool } from '../../types/drawing'
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils' import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
import { SigitFile } from '../../utils/file' import { SigitFile } from '../../utils/file'
import { getToolboxLabelByMarkType } from '../../utils/mark' import { getToolboxLabelByMarkType } from '../../utils/mark'
import { FileDivider } from '../FileDivider'
import { ExtensionFileBox } from '../ExtensionFileBox'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
import { useScale } from '../../hooks/useScale' import { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { UserAvatar } from '../UserAvatar' import { UserAvatar } from '../UserAvatar'
import _ from 'lodash' import { Updater } from 'use-immer'
import { FileItem } from './internal/FileItem'
import { FileDivider } from '../FileDivider'
import { Counterpart } from './internal/Counterpart'
const MINIMUM_RECT_SIZE = { const MINIMUM_RECT_SIZE = {
width: 21, width: 10,
height: 21 height: 10
} as const } as const
const DEFAULT_START_SIZE = { const DEFAULT_START_SIZE = {
@ -36,16 +37,25 @@ interface HideSignersForDrawnField {
[key: number]: boolean [key: number]: boolean
} }
interface Props { type PageIndexer = [file: number, page: number]
type FieldIndexer = [...PageIndexer, field: number]
interface DrawPdfFieldsProps {
users: User[] users: User[]
metadata: { [key: string]: ProfileMetadata } metadata: { [key: string]: ProfileMetadata }
sigitFiles: SigitFile[] sigitFiles: SigitFile[]
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>> updateSigitFiles: Updater<SigitFile[]>
selectedTool?: DrawTool selectedTool?: DrawTool
} }
export const DrawPDFFields = (props: Props) => { export const DrawPDFFields = ({
const { selectedTool, sigitFiles, setSigitFiles, users } = props selectedTool,
metadata,
sigitFiles,
updateSigitFiles,
users
}: DrawPdfFieldsProps) => {
const { to, from } = useScale()
const signers = users.filter((u) => u.role === UserRole.signer) const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : '' const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
@ -58,158 +68,124 @@ export const DrawPDFFields = (props: Props) => {
* @param pubkeys * @param pubkeys
* @returns available pubkey or empty string * @returns available pubkey or empty string
*/ */
const getAvailableSigner = (...pubkeys: string[]) => { const getAvailableSigner = useCallback(
const availableSigner: string | undefined = pubkeys.find((pubkey) => (...pubkeys: string[]) => {
signers.some((s) => s.pubkey === npubToHex(pubkey)) const availableSigner: string | undefined = pubkeys.find((pubkey) =>
) signers.some((s) => s.pubkey === npubToHex(pubkey))
return availableSigner || '' )
} return availableSigner || ''
},
[signers]
)
const { to, from } = useScale() const [mouseState, setMouseState] = useState<MouseState>({})
const [indexer, setIndexer] = useState<PageIndexer | FieldIndexer>()
const [mouseState, setMouseState] = useState<MouseState>({ const [field, setField] = useState<
clicked: false DrawnField & {
}) x: number
y: number
const [activeDrawnField, setActiveDrawnField] = useState<{ }
fileIndex: number >()
pageIndex: number const [lastIndexer, setLastIndexer] = useState<FieldIndexer>()
drawnFieldIndex: number
}>()
const isActiveDrawnField = ( const isActiveDrawnField = (
fileIndex: number, fileIndex: number,
pageIndex: number, pageIndex: number,
drawnFieldIndex: number drawnFieldIndex: number
) => ) =>
activeDrawnField?.fileIndex === fileIndex && lastIndexer &&
activeDrawnField?.pageIndex === pageIndex && lastIndexer[0] === fileIndex &&
activeDrawnField?.drawnFieldIndex === drawnFieldIndex lastIndexer[1] === pageIndex &&
lastIndexer[2] === drawnFieldIndex
/** /**
* Drawing events * Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* event.target will be used
*/ */
useEffect(() => { const getPointerCoordinates = (
window.addEventListener('pointerup', handlePointerUp) event: React.PointerEvent,
window.addEventListener('pointercancel', handlePointerUp) customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
return () => { // Clamp X Y within the target
window.removeEventListener('pointerup', handlePointerUp) const x = Math.max(0, Math.min(event.clientX, rect.right) - rect.left) //x position within the element.
window.removeEventListener('pointercancel', handlePointerUp) const y = Math.max(0, Math.min(event.clientY, rect.bottom) - rect.top) //y position within the element.
return {
x,
y,
rect
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const refreshPdfFiles = () => {
setSigitFiles([...sigitFiles])
} }
/** /**
* Fired only on when left (primary pointer interaction) clicking page image * Fired only on when left (primary pointer interaction) clicking page image
* Creates new drawnElement and pushes in the array * Creates new drawnElement
* It is re rendered and visible right away
* *
* @param event Pointer event * @param event Pointer event
* @param page PdfPage where press happened * @param pageIndexer File and page index
* @param pageWidth pdf value used to scale pointer coordinates
*/ */
const handlePointerDown = ( const handlePointerDown = useCallback(
event: React.PointerEvent, (
page: PdfPage, event: React.PointerEvent,
fileIndex: number, pageIndexer: PageIndexer,
pageIndex: number pageWidth: number
) => { ) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
if (!selectedTool) return
if (!selectedTool) { event.currentTarget.setPointerCapture(event.pointerId)
return const counterpart = getAvailableSigner(lastSigner, defaultSignerNpub)
}
const { x, y } = getPointerCoordinates(event)
const newField: DrawnField = {
left: to(page.width, x),
top: to(page.width, y),
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
counterpart: getAvailableSigner(lastSigner, defaultSignerNpub),
type: selectedTool.identifier
}
page.drawnFields.push(newField)
setActiveDrawnField({
fileIndex,
pageIndex,
drawnFieldIndex: page.drawnFields.length - 1
})
setMouseState((prev) => {
return {
...prev,
clicked: true
}
})
}
/**
* Drawing is finished, resets all the variables used to draw
* @param event Pointer event
*/
const handlePointerUp = () => {
sigitFiles.forEach((s) => {
s.pages?.forEach((p) => {
// Remove drawn fields below the MINIMUM_RECT_SIZE threshhold
p.drawnFields = p.drawnFields.filter(
(f) =>
!(
f.width < MINIMUM_RECT_SIZE.width ||
f.height < MINIMUM_RECT_SIZE.height
)
)
})
})
setMouseState((prev) => {
return {
...prev,
clicked: false,
dragging: false,
resizing: false
}
})
refreshPdfFiles()
}
/**
* After {@link handlePointerDown} create an drawing element, this function gets called on every pixel moved
* which alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event
* @param page PdfPage where moving is happening
*/
const handlePointerMove = (event: React.PointerEvent, page: PdfPage) => {
if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') {
const lastElementIndex = page.drawnFields.length - 1
const lastDrawnField = page.drawnFields[lastElementIndex]
// Return early if we don't have lastDrawnField
// Issue noticed in the console when dragging out of bounds
// to the page below (without releaseing mouse click)
if (!lastDrawnField) return
const { x, y } = getPointerCoordinates(event) const { x, y } = getPointerCoordinates(event)
const width = to(page.width, x) - lastDrawnField.left setIndexer(pageIndexer)
const height = to(page.width, y) - lastDrawnField.top setField({
x: to(pageWidth, x),
y: to(pageWidth, y),
left: to(pageWidth, x),
top: to(pageWidth, y),
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
type: selectedTool.identifier,
counterpart
})
setMouseState({
clicked: true
})
},
[defaultSignerNpub, getAvailableSigner, lastSigner, selectedTool, to]
)
lastDrawnField.width = width /**
lastDrawnField.height = height * After {@link handlePointerDown} creates an drawing element, this function
* alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event
* @param pageWidth pdf value used to scale pointer coordinates
*/
const handlePointerMove = useCallback(
(event: React.PointerEvent, pageWidth: number) => {
if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') {
const { x, y } = getPointerCoordinates(event)
const pageX = to(pageWidth, x)
const pageY = to(pageWidth, y)
const currentDrawnFields = page.drawnFields // Calculate left, top, width, and height based on direction
setField((prev) => {
const left = pageX < prev!.x ? pageX : prev!.x
const top = pageY < prev!.y ? pageY : prev!.y
currentDrawnFields[lastElementIndex] = lastDrawnField const width = Math.abs(pageX - prev!.x)
const height = Math.abs(pageY - prev!.y)
refreshPdfFiles() return { ...prev!, left, top, width, height }
} })
} }
},
[mouseState.clicked, selectedTool, to]
)
/** /**
* Fired when event happens on the drawn element which will be moved * Fired when event happens on the drawn element which will be moved
@ -219,22 +195,30 @@ export const DrawPDFFields = (props: Props) => {
* y - offsetY * y - offsetY
* *
* @param event Pointer event * @param event Pointer event
* @param drawnFieldIndex Which we are moving * @param fieldIndexer Which field we are moving
*/ */
const handleDrawnFieldPointerDown = ( const handleDrawnFieldPointerDown = (
event: React.PointerEvent, event: React.PointerEvent,
fileIndex: number, fieldIndexer: FieldIndexer
pageIndex: number,
drawnFieldIndex: number
) => { ) => {
event.stopPropagation() event.stopPropagation()
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
const drawingRectangleCoords = getPointerCoordinates(event) const drawingRectangleCoords = getPointerCoordinates(event)
const [fileIndex, pageIndex, drawnFieldIndex] = fieldIndexer
const page = sigitFiles[fileIndex].pages![pageIndex]
const drawnField = page.drawnFields[drawnFieldIndex]
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex }) setIndexer(fieldIndexer)
setField({
...drawnField,
x: to(page.width, drawingRectangleCoords.x),
y: to(page.width, drawingRectangleCoords.y)
})
setLastIndexer(fieldIndexer)
setMouseState({ setMouseState({
dragging: true, dragging: true,
clicked: false, clicked: false,
@ -244,7 +228,7 @@ export const DrawPDFFields = (props: Props) => {
} }
}) })
// make signers dropdown visible // Make signers dropdown visible
setHideSignersForDrawnField((prev) => ({ setHideSignersForDrawnField((prev) => ({
...prev, ...prev,
[drawnFieldIndex]: false [drawnFieldIndex]: false
@ -254,12 +238,10 @@ export const DrawPDFFields = (props: Props) => {
/** /**
* Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element) * Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element)
* @param event Pointer event * @param event Pointer event
* @param drawnField which we are moving * @param pageWidth pdf value used to scale pointer coordinates
* @param pageWidth pdf value which is used to calculate scaled offset
*/ */
const handleDrawnFieldPointerMove = ( const handleDrawnFieldPointerMove = (
event: React.PointerEvent, event: React.PointerEvent,
drawnField: DrawnField,
pageWidth: number pageWidth: number
) => { ) => {
if (mouseState.dragging) { if (mouseState.dragging) {
@ -273,18 +255,21 @@ export const DrawPDFFields = (props: Props) => {
let left = to(pageWidth, x - coordsOffset.x) let left = to(pageWidth, x - coordsOffset.x)
let top = to(pageWidth, y - coordsOffset.y) let top = to(pageWidth, y - coordsOffset.y)
const rightLimit = to(pageWidth, rect.width) - drawnField.width setField((prev) => {
const bottomLimit = to(pageWidth, rect.height) - drawnField.height const rightLimit = to(pageWidth, rect.width) - prev!.width
const bottomLimit = to(pageWidth, rect.height) - prev!.height
if (left < 0) left = 0 if (left < 0) left = 0
if (top < 0) top = 0 if (top < 0) top = 0
if (left > rightLimit) left = rightLimit if (left > rightLimit) left = rightLimit
if (top > bottomLimit) top = bottomLimit if (top > bottomLimit) top = bottomLimit
drawnField.left = left return {
drawnField.top = top ...prev!,
left,
refreshPdfFiles() top
}
})
} }
} }
} }
@ -292,73 +277,85 @@ export const DrawPDFFields = (props: Props) => {
/** /**
* Fired when clicked on the resize handle, sets the state for a resize action * Fired when clicked on the resize handle, sets the state for a resize action
* @param event Pointer event * @param event Pointer event
* @param drawnFieldIndex which we are resizing * @param fieldIndexer which field we are resizing
*/ */
const handleResizePointerDown = ( const handleResizePointerDown = useCallback(
event: React.PointerEvent, (event: React.PointerEvent, fieldIndexer: FieldIndexer) => {
fileIndex: number, // Proceed only if left click
pageIndex: number, if (event.button !== 0) return
drawnFieldIndex: number event.stopPropagation()
) => {
// Proceed only if left click
if (event.button !== 0) return
event.stopPropagation()
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex }) event.currentTarget.setPointerCapture(event.pointerId)
setMouseState({ const [fileIndex, pageIndex, drawnFieldIndex] = fieldIndexer
resizing: true const page = sigitFiles[fileIndex].pages![pageIndex]
}) const drawnField = page.drawnFields[drawnFieldIndex]
} setIndexer(fieldIndexer)
setField({
...drawnField,
x: drawnField.left,
y: drawnField.top
})
setLastIndexer(fieldIndexer)
setMouseState({
resizing: true
})
},
[sigitFiles]
)
/** /**
* Resizes the drawn element by the mouse position * Resizes the drawn element by the mouse position
* @param event Pointer event * @param event Pointer event
* @param drawnField which we are resizing * @param pageWidth pdf value used to scale pointer coordinates
* @param pageWidth pdf value which is used to calculate scaled offset
*/ */
const handleResizePointerMove = ( const handleResizePointerMove = useCallback(
event: React.PointerEvent, (event: React.PointerEvent, pageWidth: number) => {
drawnField: DrawnField, if (mouseState.resizing) {
pageWidth: number
) => {
if (mouseState.resizing) {
const { x, y } = getPointerCoordinates(
event,
// currentTarget = span handle // currentTarget = span handle
// 1st parent = drawnField // 1st parent = drawnField
// 2nd parent = img // 2nd parent = img
event.currentTarget.parentElement?.parentElement const { x, y } = getPointerCoordinates(
) event,
event.currentTarget.parentElement?.parentElement
)
const width = to(pageWidth, x) - drawnField.left const pageX = to(pageWidth, x)
const height = to(pageWidth, y) - drawnField.top const pageY = to(pageWidth, y)
drawnField.width = width setField((prev) => {
drawnField.height = height const left = pageX < prev!.x ? pageX : prev!.x
const top = pageY < prev!.y ? pageY : prev!.y
refreshPdfFiles() const width = Math.abs(pageX - prev!.x)
} const height = Math.abs(pageY - prev!.y)
} return { ...prev!, left, top, width, height }
})
}
},
[mouseState.resizing, to]
)
const handlePointerUpReleaseCapture = useCallback(
(event: React.PointerEvent) => {
event.currentTarget.releasePointerCapture(event.pointerId)
},
[]
)
/** /**
* Removes the drawn element using the indexes in the params * Removes the drawn element using the indexes in the params
* @param event Pointer event * @param event Pointer event
* @param pdfFileIndex pdf file index * @param fieldIIndexer [file index, page index, field index]
* @param pdfPageIndex pdf page index
* @param drawnFileIndex drawn file index
*/ */
const handleRemovePointerDown = ( const handleRemovePointerDown = (
event: React.PointerEvent, event: React.PointerEvent,
pdfFileIndex: number, [fileIndex, pageIndex, fieldIndex]: FieldIndexer
pdfPageIndex: number,
drawnFileIndex: number
) => { ) => {
event.stopPropagation() event.stopPropagation()
const pages = sigitFiles[pdfFileIndex]?.pages updateSigitFiles((draft) => {
if (pages) { draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1)
pages[pdfPageIndex]?.drawnFields?.splice(drawnFileIndex, 1) })
}
} }
/** /**
@ -397,28 +394,72 @@ export const DrawPDFFields = (props: Props) => {
} }
/** /**
* Gets the pointer coordinates relative to a element in the `event` param * Drawing is finished, resets all the variables used to draw
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* event.target will be used
*/ */
const getPointerCoordinates = ( const handlePointerUp = useCallback(() => {
event: React.PointerEvent, // Proceed if we have selected something
customTarget?: HTMLElement | null if (indexer) {
) => { // Check if we have the "preview" field state
const target = customTarget ? customTarget : event.currentTarget if (field) {
const rect = target.getBoundingClientRect() // Cancel update if preview field is below the MINIMUM_RECT_SIZE threshhold
if (
field.width < MINIMUM_RECT_SIZE.width ||
field.height < MINIMUM_RECT_SIZE.height
) {
setIndexer(undefined)
setMouseState({})
return
}
// Clamp X Y within the target const [fileIndex, pageIndex, fieldIndex] = indexer
const x = Math.min(event.clientX, rect.right) - rect.left //x position within the element.
const y = Math.min(event.clientY, rect.bottom) - rect.top //y position within the element.
return { // Add new drawn field to the files
x, if (mouseState.clicked) {
y, updateSigitFiles((draft) => {
rect draft[fileIndex].pages![pageIndex].drawnFields.push(field)
})
}
// Move
if (mouseState.dragging) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
})
}
// Resize
if (mouseState.resizing) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
})
}
// Clear indexer after applying the update
setIndexer(undefined)
}
} }
} setMouseState({})
}, [
field,
indexer,
mouseState.clicked,
mouseState.dragging,
mouseState.resizing,
updateSigitFiles
])
/**
* Drawing events
*/
useEffect(() => {
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [handlePointerUp])
/** /**
* Renders the pdf pages and drawing elements * Renders the pdf pages and drawing elements
@ -430,6 +471,13 @@ export const DrawPDFFields = (props: Props) => {
return ( return (
<> <>
{file.pages?.map((page, pageIndex: number) => { {file.pages?.map((page, pageIndex: number) => {
let isPageIndexerActive = false
if (indexer) {
const [fi, pi, di] = indexer
isPageIndexerActive =
fi === fileIndex && pi === pageIndex && typeof di === 'undefined'
}
return ( return (
<div <div
key={pageIndex} key={pageIndex}
@ -438,32 +486,68 @@ export const DrawPDFFields = (props: Props) => {
onKeyDown={(event) => handleEscapeButtonDown(event)} onKeyDown={(event) => handleEscapeButtonDown(event)}
> >
<img <img
onPointerMove={(event) => {
handlePointerMove(event, page)
}}
onPointerDown={(event) => { onPointerDown={(event) => {
handlePointerDown(event, page, fileIndex, pageIndex) handlePointerDown(event, [fileIndex, pageIndex], page.width)
}} }}
onPointerMove={(event) => {
handlePointerMove(event, page.width)
}}
onPointerUp={handlePointerUpReleaseCapture}
draggable="false" draggable="false"
src={page.image} src={page.image}
alt={`page ${pageIndex + 1} of ${file.name}`} alt={`page ${pageIndex + 1} of ${file.name}`}
/> />
{isPageIndexerActive && field && (
<div
className={styles.drawingRectanglePreview}
style={{
backgroundColor: field.counterpart
? `#${npubToHex(field.counterpart)?.substring(0, 6)}4b`
: undefined,
outlineColor: field.counterpart
? `#${npubToHex(field.counterpart)?.substring(0, 6)}`
: undefined,
left: inPx(from(page.width, field.left)),
top: inPx(from(page.width, field.top)),
width: inPx(from(page.width, field.width)),
height: inPx(from(page.width, field.height))
}}
>
<div
className={`file-mark ${styles.placeholder}`}
style={{
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{getToolboxLabelByMarkType(field.type) || 'placeholder'}
</div>
</div>
)}
{page.drawnFields.map((drawnField, drawnFieldIndex: number) => { {page.drawnFields.map((drawnField, drawnFieldIndex: number) => {
let isFieldIndexerActive = false
if (indexer) {
const [fi, pi, di] = indexer
isFieldIndexerActive =
fi === fileIndex &&
pi === pageIndex &&
di === drawnFieldIndex
}
return ( return (
<div <div
key={drawnFieldIndex} key={drawnFieldIndex}
onPointerDown={(event) => onPointerDown={(event) =>
handleDrawnFieldPointerDown( handleDrawnFieldPointerDown(event, [
event,
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ])
} }
onPointerMove={(event) => { onPointerMove={(event) => {
handleDrawnFieldPointerMove(event, drawnField, page.width) handleDrawnFieldPointerMove(event, page.width)
}} }}
onPointerUp={handlePointerUpReleaseCapture}
className={styles.drawingRectangle} className={styles.drawingRectangle}
style={{ style={{
backgroundColor: drawnField.counterpart backgroundColor: drawnField.counterpart
@ -472,12 +556,29 @@ export const DrawPDFFields = (props: Props) => {
outlineColor: drawnField.counterpart outlineColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
: undefined, : undefined,
left: inPx(from(page.width, drawnField.left)), ...(isFieldIndexerActive && field
top: inPx(from(page.width, drawnField.top)), ? {
width: inPx(from(page.width, drawnField.width)), left: inPx(from(page.width, field.left)),
height: inPx(from(page.width, drawnField.height)), top: inPx(from(page.width, field.top)),
width: inPx(from(page.width, field.width)),
height: inPx(from(page.width, field.height))
}
: {
left: inPx(from(page.width, drawnField.left)),
top: inPx(from(page.width, drawnField.top)),
width: inPx(from(page.width, drawnField.width)),
height: inPx(from(page.width, drawnField.height))
}),
pointerEvents: mouseState.clicked ? 'none' : 'all', pointerEvents: mouseState.clicked ? 'none' : 'all',
touchAction: 'none', touchAction: 'none',
zIndex: isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 60
: undefined,
opacity: opacity:
mouseState.dragging && mouseState.dragging &&
isActiveDrawnField( isActiveDrawnField(
@ -501,37 +602,36 @@ export const DrawPDFFields = (props: Props) => {
</div> </div>
<span <span
onPointerDown={(event) => onPointerDown={(event) =>
handleResizePointerDown( handleResizePointerDown(event, [
event,
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ])
} }
onPointerMove={(event) => { onPointerMove={(event) => {
handleResizePointerMove(event, drawnField, page.width) handleResizePointerMove(event, page.width)
}} }}
onPointerUp={handlePointerUpReleaseCapture}
className={styles.resizeHandle} className={styles.resizeHandle}
style={{ style={{
background: ...(mouseState.resizing &&
mouseState.resizing &&
isActiveDrawnField( isActiveDrawnField(
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ) && {
? 'var(--primary-main)' cursor: 'grabbing',
: undefined opacity: 0.1
})
}} }}
></span> ></span>
<span <span
onPointerDown={(event) => { onPointerDown={(event) => {
handleRemovePointerDown( handleRemovePointerDown(event, [
event,
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ])
}} }}
className={styles.removeHandle} className={styles.removeHandle}
> >
@ -569,23 +669,26 @@ export const DrawPDFFields = (props: Props) => {
onChange={(event) => { onChange={(event) => {
drawnField.counterpart = event.target.value drawnField.counterpart = event.target.value
setLastSigner(event.target.value) setLastSigner(event.target.value)
refreshPdfFiles()
}} }}
labelId="counterparts" labelId="counterparts"
label="Counterparts" label="Counterparts"
sx={{ sx={{
background: 'white' background: 'white'
}} }}
renderValue={(value) => renderValue={(value) => (
renderCounterpartValue(value) <Counterpart
} npub={value}
metadata={metadata}
signers={signers}
/>
)}
> >
{signers.map((signer, index) => { {signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey) const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey] const profileMetadata = metadata[signer.pubkey]
const displayValue = getProfileUsername( const displayValue = getProfileUsername(
npub, npub,
metadata profileMetadata
) )
// make current signers dropdown visible // make current signers dropdown visible
if ( if (
@ -604,7 +707,7 @@ export const DrawPDFFields = (props: Props) => {
<MenuItem key={index} value={npub}> <MenuItem key={index} value={npub}>
<ListItemIcon> <ListItemIcon>
<AvatarIconButton <AvatarIconButton
src={metadata?.picture} src={profileMetadata?.picture}
hexKey={signer.pubkey} hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`} aria-label={`account of user ${displayValue}`}
color="inherit" color="inherit"
@ -635,58 +738,24 @@ export const DrawPDFFields = (props: Props) => {
) )
} }
const renderCounterpartValue = (npub: string) => {
let displayValue = _.truncate(npub, { length: 16 })
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const metadata = props.metadata[signer.pubkey]
displayValue = getProfileUsername(npub, metadata)
return (
<div className={styles.counterpartSelectValue}>
<AvatarIconButton
src={props.metadata[signer.pubkey]?.picture}
hexKey={signer.pubkey || undefined}
sx={{
padding: 0,
marginRight: '6px',
'> img': {
width: '21px',
height: '21px'
}
}}
/>
{displayValue}
</div>
)
}
return displayValue
}
return ( return (
<div className="files-wrapper"> <div className="files-wrapper">
{sigitFiles.map((file, i) => { {sigitFiles.length > 0 &&
return ( sigitFiles
<React.Fragment key={file.name}> .map<React.ReactNode>((file, i) =>
<div className="file-wrapper" id={`file-${file.name}`}> file.isPdf ? (
{file.isPdf && getPdfPages(file, i)} <React.Fragment key={file.name}>
{file.isImage && ( {getPdfPages(file, i)}
<img </React.Fragment>
className="file-image" ) : (
src={file.objectUrl} <FileItem key={file.name} file={file} />
alt={file.name} )
/> )
)} .reduce((prev, curr, i) => [
{!(file.isPdf || file.isImage) && ( prev,
<ExtensionFileBox extension={file.extension} /> <FileDivider key={`separator-${i}`} />,
)} curr
</div> ])}
{i < sigitFiles.length - 1 && <FileDivider />}
</React.Fragment>
)
})}
</div> </div>
) )
} }

View File

@ -0,0 +1,3 @@
.counterpartSelectValue {
display: flex;
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import { ProfileMetadata, User } from '../../../types'
import _ from 'lodash'
import { npubToHex, getProfileUsername } from '../../../utils'
import { AvatarIconButton } from '../../UserAvatarIconButton'
import styles from './Counterpart.module.scss'
interface CounterpartProps {
npub: string
metadata: {
[key: string]: ProfileMetadata
}
signers: User[]
}
export const Counterpart = React.memo(
({ npub, metadata, signers }: CounterpartProps) => {
let displayValue = _.truncate(npub, { length: 16 })
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const signerMetadata = metadata[signer.pubkey]
displayValue = getProfileUsername(npub, signerMetadata)
return (
<div className={styles.counterpartSelectValue}>
<AvatarIconButton
src={signerMetadata.picture}
hexKey={signer.pubkey || undefined}
sx={{
padding: 0,
marginRight: '6px',
'> img': {
width: '21px',
height: '21px'
}
}}
/>
{displayValue}
</div>
)
}
return displayValue
}
)

View File

@ -0,0 +1,19 @@
import React from 'react'
import { SigitFile } from '../../../utils/file'
import { ExtensionFileBox } from '../../ExtensionFileBox'
import { ImageItem } from './ImageItem'
interface FileItemProps {
file: SigitFile
}
export const FileItem = React.memo(({ file }: FileItemProps) => {
const content = <ExtensionFileBox extension={file.extension} />
if (file.isImage) return <ImageItem file={file} />
return (
<div key={file.name} className="file-wrapper" id={`file-${file.name}`}>
{content}
</div>
)
})

View File

@ -0,0 +1,10 @@
import React from 'react'
import { SigitFile } from '../../../utils/file'
interface ImageItemProps {
file: SigitFile
}
export const ImageItem = React.memo(({ file }: ImageItemProps) => {
return <img className="file-image" src={file.objectUrl} alt={file.name} />
})

View File

@ -47,7 +47,7 @@
background-color: #fff; background-color: #fff;
border: 1px solid rgb(160, 160, 160); border: 1px solid rgb(160, 160, 160);
border-radius: 50%; border-radius: 50%;
cursor: nwse-resize; cursor: grab;
// Increase the area a bit so it's easier to click // Increase the area a bit so it's easier to click
&::after { &::after {
@ -85,10 +85,6 @@
} }
} }
.counterpartSelectValue {
display: flex;
}
.counterpartAvatar { .counterpartAvatar {
img { img {
width: 21px; width: 21px;
@ -107,3 +103,16 @@
outline: 1px dotted #01aaad; outline: 1px dotted #01aaad;
} }
} }
.drawingRectanglePreview {
position: absolute;
outline: 1px solid;
z-index: 50;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
touch-action: none;
opacity: 0.8;
}

View File

@ -7,7 +7,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
z-index: 50; z-index: 70;
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }

View File

@ -45,18 +45,17 @@ const PdfView = ({
const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => { const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => {
return marks.filter((mark) => mark.pdfFileHash === hash) return marks.filter((mark) => mark.pdfFileHash === hash)
} }
const isNotLastPdfFile = (index: number, files: CurrentUserFile[]): boolean =>
index !== files.length - 1
return ( return (
<div className="files-wrapper"> <div className="files-wrapper">
{files.length > 0 ? ( {files.length > 0 ? (
files.map((currentUserFile, index, arr) => { files
const { hash, file, id } = currentUserFile .map<React.ReactNode>((currentUserFile) => {
const { hash, file, id } = currentUserFile
if (!hash) return if (!hash) return
return ( return (
<React.Fragment key={index}>
<div <div
key={`file-${file.name}`}
id={file.name} id={file.name}
className="file-wrapper" className="file-wrapper"
ref={(el) => (pdfRefs.current[id] = el)} ref={(el) => (pdfRefs.current[id] = el)}
@ -70,10 +69,13 @@ const PdfView = ({
otherUserMarks={filterMarksByFile(otherUserMarks, hash)} otherUserMarks={filterMarksByFile(otherUserMarks, hash)}
/> />
</div> </div>
{isNotLastPdfFile(index, arr) && <FileDivider />} )
</React.Fragment> })
) .reduce((prev, curr, i) => [
}) prev,
<FileDivider key={`separator-${i}`} />,
curr
])
) : ( ) : (
<LoadingSpinner variant="small" /> <LoadingSpinner variant="small" />
)} )}

View File

@ -79,10 +79,11 @@ import {
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts' import { getSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/lab' import { Autocomplete } from '@mui/material'
import _, { truncate } from 'lodash' import _, { truncate } from 'lodash'
import * as React from 'react' import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { useImmer } from 'use-immer'
type FoundUser = Event & { npub: string } type FoundUser = Event & { npub: string }
@ -98,7 +99,7 @@ export const CreatePage = () => {
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => { const handleUploadButtonClick = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -123,7 +124,7 @@ export const CreatePage = () => {
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{} {}
) )
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false) const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null) const searchFieldRef = useRef<HTMLInputElement>(null)
@ -295,8 +296,28 @@ export const CreatePage = () => {
selectedFiles, selectedFiles,
getSigitFile getSigitFile
) )
updateDrawnFiles((draft) => {
// Existing files are untouched
setDrawnFiles(files) // Handle removed files
// Remove in reverse to avoid index issues
for (let i = draft.length - 1; i >= 0; i--) {
if (
!files.some(
(f) => f.name === draft[i].name && f.size === draft[i].size
)
) {
draft.splice(i, 1)
}
}
// Add new files
files.forEach((f) => {
if (!draft.some((d) => d.name === f.name && d.size === f.size)) {
draft.push(f)
}
})
})
} }
setIsParsing(true) setIsParsing(true)
@ -305,7 +326,7 @@ export const CreatePage = () => {
setIsParsing(false) setIsParsing(false)
}) })
} }
}, [selectedFiles]) }, [selectedFiles, updateDrawnFiles])
/** /**
* Changes the drawing tool * Changes the drawing tool
@ -357,12 +378,6 @@ export const CreatePage = () => {
}) })
}, [metadata, users]) }, [metadata, users])
useEffect(() => {
if (uploadedFiles) {
setSelectedFiles([...uploadedFiles])
}
}, [uploadedFiles])
useEffect(() => { useEffect(() => {
if (usersPubkey) { if (usersPubkey) {
setUsers((prev) => { setUsers((prev) => {
@ -516,7 +531,7 @@ export const CreatePage = () => {
}) })
}) })
}) })
setDrawnFiles(drawnFilesCopy) updateDrawnFiles(drawnFilesCopy)
} }
/** /**
@ -540,11 +555,16 @@ export const CreatePage = () => {
const files = Array.from(event.target.files) const files = Array.from(event.target.files)
// Remove duplicates based on the file.name // Remove duplicates based on the file.name
setSelectedFiles((p) => setSelectedFiles((p) => {
[...p, ...files].filter( const unique = [...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name) (file, i, array) => i === array.findIndex((t) => t.name === file.name)
) )
) navigate('.', {
state: { uploadedFiles: unique },
replace: true
})
return unique
})
} }
} }
@ -558,9 +578,14 @@ export const CreatePage = () => {
) => { ) => {
event.stopPropagation() event.stopPropagation()
setSelectedFiles((prevFiles) => setSelectedFiles((prevFiles) => {
prevFiles.filter((file) => file.name !== fileToRemove.name) const files = prevFiles.filter((file) => file.name !== fileToRemove.name)
) navigate('.', {
state: { uploadedFiles: files },
replace: true
})
return files
})
} }
// Validate inputs before proceeding // Validate inputs before proceeding
@ -1025,13 +1050,11 @@ export const CreatePage = () => {
return JSON.parse(event.content) return JSON.parse(event.content)
} catch (e) { } catch (e) {
return undefined return undefined
console.error(e)
} }
} }
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}> <Container className={styles.container}>
<StickySideColumns <StickySideColumns
left={ left={
@ -1244,19 +1267,17 @@ export const CreatePage = () => {
centerIcon={faFile} centerIcon={faFile}
rightIcon={faToolbox} rightIcon={faToolbox}
> >
{parsingPdf ? ( <DrawPDFFields
<LoadingSpinner variant="small" /> users={users}
) : ( metadata={metadata}
<DrawPDFFields selectedTool={selectedTool}
users={users} sigitFiles={drawnFiles}
metadata={metadata} updateSigitFiles={updateDrawnFiles}
selectedTool={selectedTool} />
sigitFiles={drawnFiles} {parsingPdf && <LoadingSpinner variant="small" />}
setSigitFiles={setDrawnFiles}
/>
)}
</StickySideColumns> </StickySideColumns>
</Container> </Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</> </>
) )
} }