diff --git a/package-lock.json b/package-lock.json index ef46577..479088b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { - "name": "web", + "name": "sigit", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "web", + "name": "sigit", "version": "0.0.0", "hasInstallScript": true, + "license": "AGPL-3.0-or-later ", "dependencies": { "@emotion/react": "11.11.4", "@emotion/styled": "11.11.0", @@ -40,6 +41,7 @@ "react-dropzone": "^14.2.3", "react-redux": "9.1.0", "react-router-dom": "6.22.1", + "react-singleton-hook": "^4.0.1", "react-toastify": "10.0.4", "redux": "5.0.1", "tseep": "1.2.1" @@ -5832,6 +5834,23 @@ "react-dom": ">=16.8" } }, + "node_modules/react-singleton-hook": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/react-singleton-hook/-/react-singleton-hook-4.0.1.tgz", + "integrity": "sha512-fWuk8VxcZPChrkQasDLM8pgd/7kyi+Cr/5FfCiD99FicjEru+JmtEZNnN4lJ8Z7KbqAST5CYPlpz6lmNsZFGNw==", + "license": "MIT", + "peerDependencies": { + "react": "18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-toastify": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.4.tgz", diff --git a/package.json b/package.json index c835018..a2f074d 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "react-dropzone": "^14.2.3", "react-redux": "9.1.0", "react-router-dom": "6.22.1", + "react-singleton-hook": "^4.0.1", "react-toastify": "10.0.4", "redux": "5.0.1", "tseep": "1.2.1" @@ -82,4 +83,4 @@ ], "*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}": "npm run formatter:staged" } -} \ No newline at end of file +} diff --git a/src/App.scss b/src/App.scss index f21738d..b24e16a 100644 --- a/src/App.scss +++ b/src/App.scss @@ -100,12 +100,10 @@ input { -webkit-user-select: none; user-select: none; - overflow: hidden; /* Ensure no overflow */ - > img { display: block; - max-width: 100%; - max-height: 100%; + width: 100%; + height: auto; object-fit: contain; /* Ensure the image fits within the container */ } } @@ -121,6 +119,17 @@ input { object-fit: contain; /* Ensure the image fits within the container */ } +// Consistent styling for every file mark +// Reverts some of the design defaults for font +.file-mark { + font-family: Arial; + font-size: 16px; + font-weight: normal; + color: black; + letter-spacing: normal; + border: 1px solid transparent; +} + [data-dev='true'] { .image-wrapper { // outline: 1px solid #ccc; /* Optional: for visual debugging */ diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index e5e33ca..66f6952 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -4,6 +4,8 @@ import { CircularProgress, FormControl, InputLabel, + ListItemIcon, + ListItemText, MenuItem, Select } from '@mui/material' @@ -13,10 +15,13 @@ import * as PDFJS from 'pdfjs-dist' import { ProfileMetadata, User, UserRole } from '../../types' import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { truncate } from 'lodash' -import { settleAllFullfilfedPromises, hexToNpub } from '../../utils' +import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils' import { getSigitFile, SigitFile } from '../../utils/file' import { FileDivider } from '../FileDivider' import { ExtensionFileBox } from '../ExtensionFileBox' +import { inPx } from '../../utils/pdf' +import { useScale } from '../../hooks/useScale' +import { AvatarIconButton } from '../UserAvatarIconButton' PDFJS.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', @@ -33,6 +38,7 @@ interface Props { export const DrawPDFFields = (props: Props) => { const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props + const { to, from } = useScale() const [sigitFiles, setSigitFiles] = useState([]) const [parsingPdf, setIsParsing] = useState(false) @@ -105,8 +111,8 @@ export const DrawPDFFields = (props: Props) => { const { mouseX, mouseY } = getMouseCoordinates(event) const newField: DrawnField = { - left: mouseX, - top: mouseY, + left: to(page.width, mouseX), + top: to(page.width, mouseY), width: 0, height: 0, counterpart: '', @@ -160,8 +166,8 @@ export const DrawPDFFields = (props: Props) => { const { mouseX, mouseY } = getMouseCoordinates(event) - const width = mouseX - lastDrawnField.left - const height = mouseY - lastDrawnField.top + const width = to(page.width, mouseX) - lastDrawnField.left + const height = to(page.width, mouseY) - lastDrawnField.top lastDrawnField.width = width lastDrawnField.height = height @@ -209,7 +215,8 @@ export const DrawPDFFields = (props: Props) => { */ const onDrawnFieldMouseMove = ( event: React.MouseEvent, - drawnField: DrawnField + drawnField: DrawnField, + pageWidth: number ) => { if (mouseState.dragging) { const { mouseX, mouseY, rect } = getMouseCoordinates( @@ -219,11 +226,11 @@ export const DrawPDFFields = (props: Props) => { const coordsOffset = mouseState.coordsInWrapper if (coordsOffset) { - let left = mouseX - coordsOffset.mouseX - let top = mouseY - coordsOffset.mouseY + let left = to(pageWidth, mouseX - coordsOffset.mouseX) + let top = to(pageWidth, mouseY - coordsOffset.mouseY) - const rightLimit = rect.width - drawnField.width - 3 - const bottomLimit = rect.height - drawnField.height - 3 + const rightLimit = to(pageWidth, rect.width) - drawnField.width - 3 + const bottomLimit = to(pageWidth, rect.height) - drawnField.height - 3 if (left < 0) left = 0 if (top < 0) top = 0 @@ -263,7 +270,8 @@ export const DrawPDFFields = (props: Props) => { */ const onResizeHandleMouseMove = ( event: React.MouseEvent, - drawnField: DrawnField + drawnField: DrawnField, + pageWidth: number ) => { if (mouseState.resizing) { const { mouseX, mouseY } = getMouseCoordinates( @@ -274,8 +282,8 @@ export const DrawPDFFields = (props: Props) => { event.currentTarget.parentElement?.parentElement ) - const width = mouseX - drawnField.left - const height = mouseY - drawnField.top + const width = to(pageWidth, mouseX) - drawnField.left + const height = to(pageWidth, mouseY) - drawnField.top drawnField.width = width drawnField.height = height @@ -372,21 +380,21 @@ export const DrawPDFFields = (props: Props) => { key={drawnFieldIndex} onMouseDown={onDrawnFieldMouseDown} onMouseMove={(event) => { - onDrawnFieldMouseMove(event, drawnField) + onDrawnFieldMouseMove(event, drawnField, page.width) }} className={styles.drawingRectangle} style={{ - left: `${drawnField.left}px`, - top: `${drawnField.top}px`, - width: `${drawnField.width}px`, - height: `${drawnField.height}px`, + 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' }} > { - onResizeHandleMouseMove(event, drawnField) + onResizeHandleMouseMove(event, drawnField, page.width) }} className={styles.resizeHandle} > @@ -420,6 +428,7 @@ export const DrawPDFFields = (props: Props) => { sx={{ background: 'white' }} + renderValue={(value) => renderCounterpartValue(value)} > {users .filter((u) => u.role === UserRole.signer) @@ -448,7 +457,22 @@ export const DrawPDFFields = (props: Props) => { key={index} value={hexToNpub(user.pubkey)} > - {displayValue} + + img': { + width: '30px', + height: '30px' + } + }} + /> + + {displayValue} ) })} @@ -465,6 +489,45 @@ export const DrawPDFFields = (props: Props) => { ) } + const renderCounterpartValue = (value: string) => { + const user = users.find((u) => u.pubkey === npubToHex(value)) + if (user) { + let displayValue = truncate(value, { + length: 16 + }) + + const metadata = props.metadata[user.pubkey] + + if (metadata) { + displayValue = truncate( + metadata.name || metadata.display_name || metadata.username || value, + { + length: 16 + } + ) + } + return ( + <> + img': { + width: '21px', + height: '21px' + } + }} + /> + {displayValue} + + ) + } + + return value + } + if (parsingPdf) { return ( diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index b3150b3..62fa688 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -73,7 +73,7 @@ justify-content: center; align-items: center; bottom: -60px; - min-width: 170px; + min-width: 193px; min-height: 30px; padding: 5px 0; } diff --git a/src/components/PDFView/PdfMarkItem.tsx b/src/components/PDFView/PdfMarkItem.tsx index d93c2b2..d5a7c78 100644 --- a/src/components/PDFView/PdfMarkItem.tsx +++ b/src/components/PDFView/PdfMarkItem.tsx @@ -1,12 +1,14 @@ import { CurrentUserMark } from '../../types/mark.ts' import styles from '../DrawPDFFields/style.module.scss' -import { inPx } from '../../utils/pdf.ts' +import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' +import { useScale } from '../../hooks/useScale.tsx' interface PdfMarkItemProps { userMark: CurrentUserMark handleMarkClick: (id: number) => void selectedMarkValue: string selectedMark: CurrentUserMark | null + pageWidth: number } /** @@ -16,22 +18,26 @@ const PdfMarkItem = ({ selectedMark, handleMarkClick, selectedMarkValue, - userMark + userMark, + pageWidth }: PdfMarkItemProps) => { const { location } = userMark.mark const handleClick = () => handleMarkClick(userMark.mark.id) const isEdited = () => selectedMark?.mark.id === userMark.mark.id const getMarkValue = () => isEdited() ? selectedMarkValue : userMark.currentValue + const { from } = useScale() return (
{getMarkValue()} diff --git a/src/components/PDFView/PdfPageItem.tsx b/src/components/PDFView/PdfPageItem.tsx index 65ea322..518f06d 100644 --- a/src/components/PDFView/PdfPageItem.tsx +++ b/src/components/PDFView/PdfPageItem.tsx @@ -4,7 +4,8 @@ import { CurrentUserMark, Mark } from '../../types/mark.ts' import PdfMarkItem from './PdfMarkItem.tsx' import { useEffect, useRef } from 'react' import pdfViewStyles from './style.module.scss' -import { inPx } from '../../utils/pdf.ts' +import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' +import { useScale } from '../../hooks/useScale.tsx' interface PdfPageProps { currentUserMarks: CurrentUserMark[] handleMarkClick: (id: number) => void @@ -33,6 +34,8 @@ const PdfPageItem = ({ } }, [selectedMark]) const markRefs = useRef<(HTMLDivElement | null)[]>([]) + const { from } = useScale() + return (
@@ -44,23 +47,28 @@ const PdfPageItem = ({ selectedMarkValue={selectedMarkValue} userMark={m} selectedMark={selectedMark} + pageWidth={page.width} />
))} - {otherUserMarks.map((m, i) => ( -
- {m.value} -
- ))} + {otherUserMarks.map((m, i) => { + return ( +
+ {m.value} +
+ ) + })}
) } diff --git a/src/hooks/useScale.tsx b/src/hooks/useScale.tsx new file mode 100644 index 0000000..406928b --- /dev/null +++ b/src/hooks/useScale.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import { singletonHook } from 'react-singleton-hook' +import { getInnerContentWidth } from '../utils/pdf' + +const noScaleInit = { + to: (_: number, v: number) => v, + from: (_: number, v: number) => v +} + +const useScaleImpl = () => { + const [width, setWidth] = useState(getInnerContentWidth()) + + // Get the scale based on the original width + const scale = (originalWidth: number) => { + return width / originalWidth + } + + // Get the original pixel value + const to = (originalWidth: number, value: number) => { + return value / scale(originalWidth) + } + + // Get the scaled pixel value + const from = (originalWidth: number, value: number) => { + return value * scale(originalWidth) + } + + const resize = () => { + setWidth(getInnerContentWidth()) + } + + useEffect(() => { + resize() + + window.addEventListener('resize', resize) + return () => { + window.removeEventListener('resize', resize) + } + }, []) + + return { to, from } +} + +export const useScale = singletonHook(noScaleInit, useScaleImpl, { + unmountIfNoConsumers: true +}) diff --git a/src/layouts/StickySideColumns.module.scss b/src/layouts/StickySideColumns.module.scss index 7495cad..77daa03 100644 --- a/src/layouts/StickySideColumns.module.scss +++ b/src/layouts/StickySideColumns.module.scss @@ -29,8 +29,4 @@ padding: 10px; border: 10px solid $overlay-background-color; border-radius: 4px; - - max-width: 590px; - width: 590px; - margin: 0 auto; } diff --git a/src/layouts/StickySideColumns.tsx b/src/layouts/StickySideColumns.tsx index 1ada87f..43dc430 100644 --- a/src/layouts/StickySideColumns.tsx +++ b/src/layouts/StickySideColumns.tsx @@ -17,7 +17,9 @@ export const StickySideColumns = ({
{left}
-
{children}
+
+ {children} +
{right}
diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 4e21a82..8619806 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -29,6 +29,8 @@ import axios from 'axios' import { addMarks, convertToPdfBlob, + FONT_SIZE, + FONT_TYPE, groupMarksByFileNamePage, inPx } from '../../utils/pdf.ts' @@ -50,6 +52,7 @@ import React from 'react' import { convertToSigitFile, SigitFile } from '../../utils/file.ts' import { FileDivider } from '../../components/FileDivider.tsx' import { ExtensionFileBox } from '../../components/ExtensionFileBox.tsx' +import { useScale } from '../../hooks/useScale.tsx' interface PdfViewProps { files: CurrentUserFile[] @@ -65,6 +68,7 @@ const SlimPdfView = ({ parsedSignatureEvents }: PdfViewProps) => { const pdfRefs = useRef<(HTMLDivElement | null)[]>([]) + const { from } = useScale() useEffect(() => { if (currentFile !== null && !!pdfRefs.current[currentFile.id]) { pdfRefs.current[currentFile.id]?.scrollIntoView({ @@ -105,13 +109,15 @@ const SlimPdfView = ({ {marks.map((m) => { return (
{m.value} diff --git a/src/types/drawing.ts b/src/types/drawing.ts index b8abe73..677b68c 100644 --- a/src/types/drawing.ts +++ b/src/types/drawing.ts @@ -1,3 +1,5 @@ +import { MarkRect } from './mark' + export interface MouseState { clicked?: boolean dragging?: boolean @@ -10,14 +12,11 @@ export interface MouseState { export interface PdfPage { image: string + width: number drawnFields: DrawnField[] } -export interface DrawnField { - left: number - top: number - width: number - height: number +export interface DrawnField extends MarkRect { type: MarkType /** * npub of a counter part diff --git a/src/types/mark.ts b/src/types/mark.ts index efc1899..df733d6 100644 --- a/src/types/mark.ts +++ b/src/types/mark.ts @@ -18,10 +18,13 @@ export interface Mark { value?: string } -export interface MarkLocation { - top: number - left: number - height: number - width: number +export interface MarkLocation extends MarkRect { page: number } + +export interface MarkRect { + left: number + top: number + width: number + height: number +} diff --git a/src/utils/file.ts b/src/utils/file.ts index aad31c7..63d40e5 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -74,6 +74,7 @@ export const getSigitFile = async (file: File) => { const sigitFile = new SigitFile(file) // Process sigit file // - generate pages for PDF files + // - generate ObjectRL for image files await sigitFile.process() return sigitFile } diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 8abafae..1dd75f6 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -8,11 +8,6 @@ PDFJS.GlobalWorkerOptions.workerSrc = new URL( import.meta.url ).toString() -/** - * Scale between the PDF page's natural size and rendered size - * @constant {number} - */ -export const SCALE: number = 3 /** * Defined font size used when generating a PDF. Currently it is difficult to fully * correlate font size used at the time of filling in / drawing on the PDF @@ -20,14 +15,14 @@ export const SCALE: number = 3 * This should be fixed going forward. * Switching to PDF-Lib will most likely make this problem redundant. */ -export const FONT_SIZE: number = 40 +export const FONT_SIZE: number = 16 /** * Current font type used when generating a PDF. */ export const FONT_TYPE: string = 'Arial' /** - * A utility that transforms a drawing coordinate number into a CSS-compatible string + * A utility that transforms a drawing coordinate number into a CSS-compatible pixel string * @param coordinate */ export const inPx = (coordinate: number): string => `${coordinate}px` @@ -65,6 +60,24 @@ export const readPdf = (file: File): Promise => { }) } +export const getInnerContentWidth = () => { + // Fetch the first container element we find + const element = document.querySelector('#content-preview') + + if (element) { + const style = getComputedStyle(element) + + // Calculate width without padding + const widthWithoutPadding = + element.clientWidth - parseFloat(style.padding) * 2 + + return widthWithoutPadding + } + + // Default value + return 620 +} + /** * Converts pdf to the images * @param data pdf file bytes @@ -72,28 +85,30 @@ export const readPdf = (file: File): Promise => { export const pdfToImages = async ( data: string | ArrayBuffer ): Promise => { - const images: string[] = [] + const pages: PdfPage[] = [] const pdf = await PDFJS.getDocument(data).promise const canvas = document.createElement('canvas') + const width = getInnerContentWidth() for (let i = 0; i < pdf.numPages; i++) { const page = await pdf.getPage(i + 1) - const viewport = page.getViewport({ scale: SCALE }) + + const originalViewport = page.getViewport({ scale: 1 }) + const scale = width / originalViewport.width + const viewport = page.getViewport({ scale: scale }) 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()) + pages.push({ + image: canvas.toDataURL(), + width: originalViewport.width, + drawnFields: [] + }) } - return Promise.resolve( - images.map((image) => { - return { - image, - drawnFields: [] - } - }) - ) + return pages } /** @@ -113,34 +128,39 @@ export const addMarks = async ( for (let i = 0; i < pdf.numPages; i++) { const page = await pdf.getPage(i + 1) - const viewport = page.getViewport({ scale: SCALE }) + 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 + if (context) { + await page.render({ canvasContext: context, viewport: viewport }).promise - if (marksPerPage && Object.hasOwn(marksPerPage, i)) - marksPerPage[i]?.forEach((mark) => draw(mark, context!)) + if (marksPerPage && Object.hasOwn(marksPerPage, i)) { + marksPerPage[i]?.forEach((mark) => draw(mark, context)) + } - images.push(canvas.toDataURL()) + images.push(canvas.toDataURL()) + } } - return Promise.resolve(images) + canvas.remove() + + return images } /** * Utility to scale mark in line with the PDF-to-PNG scale */ -export const scaleMark = (mark: Mark): Mark => { +export const scaleMark = (mark: Mark, scale: number): Mark => { const { location } = mark return { ...mark, location: { ...location, - width: location.width * SCALE, - height: location.height * SCALE, - left: location.left * SCALE, - top: location.top * SCALE + width: location.width * scale, + height: location.height * scale, + left: location.left * scale, + top: location.top * scale } } } @@ -158,13 +178,14 @@ export const hasValue = (mark: Mark): boolean => !!mark.value */ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { const { location } = mark - - ctx!.font = FONT_SIZE + 'px ' + FONT_TYPE - ctx!.fillStyle = 'black' - const textMetrics = ctx!.measureText(mark.value!) + ctx.font = FONT_SIZE + 'px ' + FONT_TYPE + ctx.fillStyle = 'black' + const textMetrics = ctx.measureText(mark.value!) + const textHeight = + textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent const textX = location.left + (location.width - textMetrics.width) / 2 - const textY = location.top + (location.height + parseInt(ctx!.font)) / 2 - ctx!.fillText(mark.value!, textX, textY) + const textY = location.top + (location.height + textHeight) / 2 + ctx.fillText(mark.value!, textX, textY) } /** @@ -194,13 +215,11 @@ export const convertToPdfBlob = async ( /** * @param marks - an array of Marks * @function hasValue removes any Mark without a property - * @function scaleMark scales remaining marks in line with SCALE * @function byPage groups remaining Marks by their page marks.location.page */ export const groupMarksByFileNamePage = (marks: Mark[]) => { return marks .filter(hasValue) - .map(scaleMark) .reduce<{ [fileName: string]: { [page: number]: Mark[] } }>(byPage, {}) }