refactor: use signature pad, smoother signature line

BREAKING CHANGE: mark.value type changed
This commit is contained in:
enes 2024-11-08 16:58:27 +01:00
parent 4cb6f07a68
commit 7c7a222d4f
9 changed files with 85 additions and 86 deletions

13
package-lock.json generated
View File

@ -47,6 +47,7 @@
"react-singleton-hook": "^4.0.1", "react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4",
"svgo": "^3.3.2", "svgo": "^3.3.2",
"tseep": "1.2.1" "tseep": "1.2.1"
}, },
@ -4020,9 +4021,9 @@
"dev": true "dev": true
}, },
"node_modules/elliptic": { "node_modules/elliptic": {
"version": "6.5.7", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.0.tgz",
"integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", "integrity": "sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7866,6 +7867,12 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"optional": true "optional": true
}, },
"node_modules/signature_pad": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.0.4.tgz",
"integrity": "sha512-nngOixbwLAUOuH3QnZwlgwmynQblxmo4iWacKFwfymJfiY+Qt+9icNtcIe/okqXKun4hJ5QTFmHyC7dmv6lf2w==",
"license": "MIT"
},
"node_modules/simple-concat": { "node_modules/simple-concat": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",

View File

@ -57,6 +57,7 @@
"react-singleton-hook": "^4.0.1", "react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4",
"svgo": "^3.3.2", "svgo": "^3.3.2",
"tseep": "1.2.1" "tseep": "1.2.1"
}, },

View File

@ -1,10 +1,13 @@
import { useRef, useState } from 'react' import { useEffect, useRef } from 'react'
import { MarkInputProps } from '../../types/mark' import { MarkInputProps } from '../../types/mark'
import { getOptimizedPaths, optimizeSVG } from '../../utils' import { getOptimizedPathsWithStrokeWidth } from '../../utils'
import { faEraser } from '@fortawesome/free-solid-svg-icons' import { faEraser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import styles from './Signature.module.scss' import styles from './Signature.module.scss'
import { MarkRenderSignature } from '../MarkRender/Signature' import { MarkRenderSignature } from '../MarkRender/Signature'
import SignaturePad from 'signature_pad'
import { Config, optimize } from 'svgo'
import { SIGNATURE_PAD_OPTIONS } from '../../utils/const'
export const MarkInputSignature = ({ export const MarkInputSignature = ({
value, value,
@ -12,64 +15,44 @@ export const MarkInputSignature = ({
userMark userMark
}: MarkInputProps) => { }: MarkInputProps) => {
const location = userMark?.mark.location const location = userMark?.mark.location
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const [drawing, setDrawing] = useState(false) const signaturePad = useRef<SignaturePad | null>(null)
const [paths, setPaths] = useState<string[]>(value ? JSON.parse(value) : [])
function update() { const update = () => {
if (location && paths) { if (signaturePad.current && signaturePad.current?.toData()) {
if (paths.length) { const svg = signaturePad.current.toSVG()
const optimizedSvg = optimizeSVG(location, paths) const optimizedSvg = optimize(svg, {
const extractedPaths = getOptimizedPaths(optimizedSvg) multipass: true, // Optimize multiple times if needed
handler(JSON.stringify(extractedPaths)) floatPrecision: 2 // Adjust precision
} else { } as Config).data
handler('') const extractedSegments = getOptimizedPathsWithStrokeWidth(optimizedSvg)
} handler(JSON.stringify(extractedSegments))
} else {
handler('')
} }
} }
const handlePointerDown = (event: React.PointerEvent) => { useEffect(() => {
const rect = event.currentTarget.getBoundingClientRect() const handleEndStroke = () => {
const x = event.clientX - rect.left update()
const y = event.clientY - rect.top }
if (location && canvasRef.current && signaturePad.current === null) {
signaturePad.current = new SignaturePad(
canvasRef.current,
SIGNATURE_PAD_OPTIONS
)
signaturePad.current.addEventListener('endStroke', handleEndStroke)
}
const ctx = canvasRef.current?.getContext('2d') return () => {
ctx?.beginPath() window.removeEventListener('endStroke', handleEndStroke)
ctx?.moveTo(x, y) }
setPaths([...paths, `M ${x} ${y}`]) // eslint-disable-next-line react-hooks/exhaustive-deps
setDrawing(true) }, [])
}
const handlePointerUp = () => {
setDrawing(false)
update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
}
const handlePointerMove = (event: React.PointerEvent) => {
if (!drawing) return
const ctx = canvasRef.current?.getContext('2d')
const rect = canvasRef.current?.getBoundingClientRect()
const x = event.clientX - rect!.left
const y = event.clientY - rect!.top
ctx?.lineTo(x, y)
ctx?.stroke()
// Collect the path data
setPaths((prevPaths) => {
const newPaths = [...prevPaths]
newPaths[newPaths.length - 1] += ` L ${x} ${y}`
return newPaths
})
}
const handleReset = () => { const handleReset = () => {
setPaths([]) signaturePad.current?.clear()
setDrawing(false)
update() update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
} }
return ( return (
@ -81,10 +64,6 @@ export const MarkInputSignature = ({
width={location?.width} width={location?.width}
ref={canvasRef} ref={canvasRef}
className={styles.canvas} className={styles.canvas}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerOut={handlePointerUp}
></canvas> ></canvas>
{typeof userMark?.mark !== 'undefined' && ( {typeof userMark?.mark !== 'undefined' && (
<div className={styles.absolute}> <div className={styles.absolute}>

View File

@ -1,12 +1,15 @@
import { MarkRenderProps } from '../../types/mark' import { MarkRenderProps } from '../../types/mark'
export const MarkRenderSignature = ({ value, mark }: MarkRenderProps) => { export const MarkRenderSignature = ({ value, mark }: MarkRenderProps) => {
const paths = value ? JSON.parse(value) : [] const segments: string[][] = value ? JSON.parse(value) : []
return ( return (
<svg viewBox={`0 0 ${mark.location.width} ${mark.location.height}`}> <svg
{paths.map((path: string) => ( viewBox={`0 0 ${mark.location.width} ${mark.location.height}`}
<path d={path} stroke="black" fill="none" /> strokeLinecap="round"
>
{segments.map(([path, width]) => (
<path d={path} strokeWidth={width} stroke="black" fill="none" />
))} ))}
</svg> </svg>
) )

View File

@ -117,7 +117,9 @@ const PdfMarking = (props: PdfMarkingProps) => {
// setCurrentUserMarks(updatedCurrentUserMarks) // setCurrentUserMarks(updatedCurrentUserMarks)
// } // }
const handleChange = (value: string) => setSelectedMarkValue(value) const handleChange = (value: string) => {
setSelectedMarkValue(value)
}
return ( return (
<> <>

View File

@ -112,3 +112,8 @@ export const MOST_COMMON_MEDIA_TYPES = new Map([
['3g2', 'video/3gpp2'], // 3GPP2 audio/video container ['3g2', 'video/3gpp2'], // 3GPP2 audio/video container
['7z', 'application/x-7z-compressed'] // 7-zip archive ['7z', 'application/x-7z-compressed'] // 7-zip archive
]) ])
export const SIGNATURE_PAD_OPTIONS = {
minWidth: 0.5,
maxWidth: 3
} as const

View File

@ -11,3 +11,4 @@ export * from './string'
export * from './url' export * from './url'
export * from './utils' export * from './utils'
export * from './zip' export * from './zip'
export * from './const'

View File

@ -1,4 +1,4 @@
import { CurrentUserMark, Mark, MarkLocation } from '../types/mark.ts' import { CurrentUserMark, Mark } from '../types/mark.ts'
import { hexToNpub } from './nostr.ts' import { hexToNpub } from './nostr.ts'
import { Meta, SignedEventContent } from '../types' import { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -24,7 +24,6 @@ import {
faStamp, faStamp,
faTableCellsLarge faTableCellsLarge
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { Config, optimize } from 'svgo'
/** /**
* Takes in an array of Marks already filtered by User. * Takes in an array of Marks already filtered by User.
@ -266,22 +265,18 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
} }
export const optimizeSVG = (location: MarkLocation, paths: string[]) => { export const getOptimizedPathsWithStrokeWidth = (svgString: string) => {
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${location.width}" height="${location.height}">${paths.map((path) => `<path d="${path}" stroke="black" fill="none" />`).join('')}</svg>` const parser = new DOMParser()
const optimizedSVG = optimize(svgContent, { const xmlDoc = parser.parseFromString(svgString, 'image/svg+xml')
multipass: true, // Optimize multiple times if needed const paths = xmlDoc.querySelectorAll('path')
floatPrecision: 2 // Adjust precision const tuples: string[][] = []
} as Config) paths.forEach((path) => {
const d = path.getAttribute('d') ?? ''
const strokeWidth = path.getAttribute('stroke-width') ?? ''
tuples.push([d, strokeWidth])
})
return optimizedSVG.data return tuples
}
export const getOptimizedPaths = (svgString: string) => {
const regex = / d="([^"]*)"/g
const matches = [...svgString.matchAll(regex)]
const pathValues = matches.map((match) => match[1])
return pathValues
} }
export { export {

View File

@ -1,5 +1,5 @@
import { MarkType, PdfPage } from '../types/drawing.ts' import { MarkType, PdfPage } from '../types/drawing.ts'
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib' import { LineCapStyle, PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
import { Mark } from '../types/mark.ts' import { Mark } from '../types/mark.ts'
import * as PDFJS from 'pdfjs-dist' import * as PDFJS from 'pdfjs-dist'
import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker' import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker'
@ -132,7 +132,8 @@ export const addMarks = async (
for (let i = 0; i < pages.length; i++) { for (let i = 0; i < pages.length; i++) {
if (marksPerPage && Object.hasOwn(marksPerPage, i)) { if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
marksPerPage[i]?.forEach((mark) => { for (let j = 0; j < marksPerPage[i].length; j++) {
const mark = marksPerPage[i][j]
switch (mark.type) { switch (mark.type) {
case MarkType.SIGNATURE: case MarkType.SIGNATURE:
drawSignatureText(mark, pages[i]) drawSignatureText(mark, pages[i])
@ -142,7 +143,7 @@ export const addMarks = async (
drawMarkText(mark, pages[i], robotoFont) drawMarkText(mark, pages[i], robotoFont)
break break
} }
}) }
} }
} }
@ -263,9 +264,14 @@ const drawSignatureText = (mark: Mark, page: PDFPage) => {
const y = height - location.top const y = height - location.top
if (hasValue(mark)) { if (hasValue(mark)) {
const paths = JSON.parse(mark.value!) const segments: string[][] = JSON.parse(mark.value!)
paths.forEach((d: string) => { segments.forEach(([d, w]) => {
page.drawSvgPath(d, { x, y }) page.drawSvgPath(d, {
x,
y,
borderWidth: parseFloat(w),
borderLineCap: LineCapStyle.Round
})
}) })
} }
} }