Compare commits

..

4 Commits

Author SHA1 Message Date
b
cc059f6cb4 Merge pull request 'feat: signature squiggle' (#237) from feat/signature into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m33s
Reviewed-on: #237
Reviewed-by: b <b@4j.cx>
2024-10-28 16:23:28 +00:00
enes
de44370a96 feat: add squiggle support
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 36s
2024-10-25 18:42:16 +02:00
enes
dfa2832e8d feat: add MarkConfig and components 2024-10-25 18:40:50 +02:00
enes
9286e4304f feat: add SVGO, enable signature 2024-10-25 18:38:47 +02:00
15 changed files with 1930 additions and 31 deletions

1633
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",
"svgo": "^3.3.2",
"tseep": "1.2.1" "tseep": "1.2.1"
}, },
"devDependencies": { "devDependencies": {
@ -66,6 +67,7 @@
"@types/pdfjs-dist": "^2.10.378", "@types/pdfjs-dist": "^2.10.378",
"@types/react": "^18.2.56", "@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/svgo": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2", "@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@ -78,6 +80,7 @@
"ts-css-modules-vite-plugin": "1.0.20", "ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-tsconfig-paths": "4.3.2" "vite-tsconfig-paths": "4.3.2"
}, },
"lint-staged": { "lint-staged": {

View File

@ -7,13 +7,12 @@ import {
isCurrentValueLast isCurrentValueLast
} from '../../utils' } from '../../utils'
import React, { useState } from 'react' import React, { useState } from 'react'
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
interface MarkFormFieldProps { interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: ( handleSelectedMarkValueChange: (value: string) => void
event: React.ChangeEvent<HTMLInputElement>
) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
selectedMark: CurrentUserMark selectedMark: CurrentUserMark
selectedMarkValue: string selectedMarkValue: string
@ -53,6 +52,8 @@ const MarkFormField = ({
} }
const toggleActions = () => setDisplayActions(!displayActions) const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type) const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
const { input: MarkInputComponent } =
MARK_TYPE_CONFIG[selectedMark.mark.type] || {}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.trigger}> <div className={styles.trigger}>
@ -83,12 +84,14 @@ const MarkFormField = ({
</div> </div>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
<form onSubmit={(e) => handleFormSubmit(e)}> <form onSubmit={(e) => handleFormSubmit(e)}>
<input {typeof MarkInputComponent !== 'undefined' && (
className={styles.input} <MarkInputComponent
placeholder={markLabel}
onChange={handleSelectedMarkValueChange}
value={selectedMarkValue} value={selectedMarkValue}
placeholder={markLabel}
handler={handleSelectedMarkValueChange}
userMark={selectedMark}
/> />
)}
<div className={styles.actionsBottom}> <div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}> <button type="submit" className={styles.submitButton}>
NEXT NEXT

View File

@ -0,0 +1,44 @@
@import '../../styles/colors.scss';
$padding: 5px;
.wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: $padding;
}
.relative {
position: relative;
}
.canvas {
outline: 1px solid black;
background-color: $body-background-color;
cursor: crosshair;
// Disable panning/zooming when touching canvas element
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.absolute {
position: absolute;
inset: 0;
pointer-events: none;
}
.reset {
cursor: pointer;
position: absolute;
top: 0;
right: $padding;
color: $primary-main;
&:hover {
color: $primary-dark;
}
}

View File

@ -0,0 +1,101 @@
import { useRef, useState } from 'react'
import { MarkInputProps } from '../../types/mark'
import { getOptimizedPaths, optimizeSVG } from '../../utils'
import { faEraser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import styles from './Signature.module.scss'
import { MarkRenderSignature } from '../MarkRender/Signature'
export const MarkInputSignature = ({
value,
handler,
userMark
}: MarkInputProps) => {
const location = userMark?.mark.location
const canvasRef = useRef<HTMLCanvasElement>(null)
const [drawing, setDrawing] = useState(false)
const [paths, setPaths] = useState<string[]>(value ? JSON.parse(value) : [])
function update() {
if (location && paths) {
if (paths.length) {
const optimizedSvg = optimizeSVG(location, paths)
const extractedPaths = getOptimizedPaths(optimizedSvg)
handler(JSON.stringify(extractedPaths))
} else {
handler('')
}
}
}
const handlePointerDown = (event: React.PointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const ctx = canvasRef.current?.getContext('2d')
ctx?.beginPath()
ctx?.moveTo(x, y)
setPaths([...paths, `M ${x} ${y}`])
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 = () => {
setPaths([])
setDrawing(false)
update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
}
return (
<>
<div className={styles.wrapper}>
<div className={styles.relative}>
<canvas
height={location?.height}
width={location?.width}
ref={canvasRef}
className={styles.canvas}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerOut={handlePointerUp}
></canvas>
{typeof userMark?.mark !== 'undefined' && (
<div className={styles.absolute}>
<MarkRenderSignature value={value} mark={userMark.mark} />
</div>
)}
<div className={styles.reset}>
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,19 @@
import { MarkInputProps } from '../../types/mark'
import styles from '../MarkFormField/style.module.scss'
export const MarkInputText = ({
value,
handler,
placeholder
}: MarkInputProps) => {
return (
<input
className={styles.input}
placeholder={placeholder}
onChange={(e) => {
handler(e.currentTarget.value)
}}
value={value}
/>
)
}

View File

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

View File

@ -4,6 +4,7 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx' import { useScale } from '../../hooks/useScale.tsx'
import { forwardRef } from 'react' import { forwardRef } from 'react'
import { npubToHex } from '../../utils/nostr.ts' import { npubToHex } from '../../utils/nostr.ts'
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
interface PdfMarkItemProps { interface PdfMarkItemProps {
userMark: CurrentUserMark userMark: CurrentUserMark
@ -27,6 +28,8 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
const getMarkValue = () => const getMarkValue = () =>
isEdited() ? selectedMarkValue : userMark.currentValue isEdited() ? selectedMarkValue : userMark.currentValue
const { from } = useScale() const { from } = useScale()
const { render: MarkRenderComponent } =
MARK_TYPE_CONFIG[userMark.mark.type] || {}
return ( return (
<div <div
ref={ref} ref={ref}
@ -47,7 +50,9 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
fontSize: inPx(from(pageWidth, FONT_SIZE)) fontSize: inPx(from(pageWidth, FONT_SIZE))
}} }}
> >
{getMarkValue()} {typeof MarkRenderComponent !== 'undefined' && (
<MarkRenderComponent value={getMarkValue()} mark={userMark.mark} />
)}
</div> </div>
) )
} }

View File

@ -117,8 +117,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
// setCurrentUserMarks(updatedCurrentUserMarks) // setCurrentUserMarks(updatedCurrentUserMarks)
// } // }
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => const handleChange = (value: string) => setSelectedMarkValue(value)
setSelectedMarkValue(event.target.value)
return ( return (
<> <>

View File

@ -0,0 +1,16 @@
import { MarkType } from '../types/drawing'
import { MarkConfigs } from '../types/mark'
import { MarkInputSignature } from './MarkInputs/Signature'
import { MarkInputText } from './MarkInputs/Text'
import { MarkRenderSignature } from './MarkRender/Signature'
export const MARK_TYPE_CONFIG: MarkConfigs = {
[MarkType.TEXT]: {
input: MarkInputText,
render: ({ value }) => <>{value}</>
},
[MarkType.SIGNATURE]: {
input: MarkInputSignature,
render: MarkRenderSignature
}
}

View File

@ -55,6 +55,7 @@ import {
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts' import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
import _ from 'lodash' import _ from 'lodash'
import { MARK_TYPE_CONFIG } from '../../components/getMarkComponents.tsx'
interface PdfViewProps { interface PdfViewProps {
files: CurrentUserFile[] files: CurrentUserFile[]
@ -114,6 +115,8 @@ const SlimPdfView = ({
alt={`page ${i} of ${file.name}`} alt={`page ${i} of ${file.name}`}
/> />
{marks.map((m) => { {marks.map((m) => {
const { render: MarkRenderComponent } =
MARK_TYPE_CONFIG[m.type] || {}
return ( return (
<div <div
className={`file-mark ${styles.mark}`} className={`file-mark ${styles.mark}`}
@ -129,7 +132,9 @@ const SlimPdfView = ({
fontSize: inPx(from(page.width, FONT_SIZE)) fontSize: inPx(from(page.width, FONT_SIZE))
}} }}
> >
{m.value} {typeof MarkRenderComponent !== 'undefined' && (
<MarkRenderComponent value={m.value} mark={m} />
)}
</div> </div>
) )
})} })}

View File

@ -28,3 +28,24 @@ export interface MarkRect {
width: number width: number
height: number height: number
} }
export interface MarkInputProps {
value: string
handler: (value: string) => void
placeholder?: string
userMark?: CurrentUserMark
}
export interface MarkRenderProps {
value?: string
mark: Mark
}
export interface MarkConfig {
input: React.FC<MarkInputProps>
render?: React.FC<MarkRenderProps>
}
export type MarkConfigs = {
[key in MarkType]?: MarkConfig
}

View File

@ -1,4 +1,4 @@
import { CurrentUserMark, Mark } from '../types/mark.ts' import { CurrentUserMark, Mark, MarkLocation } 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,6 +24,7 @@ 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.
@ -158,6 +159,11 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
icon: faT, icon: faT,
label: 'Text' label: 'Text'
}, },
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature'
},
{ {
identifier: MarkType.FULLNAME, identifier: MarkType.FULLNAME,
icon: faIdCard, icon: faIdCard,
@ -170,12 +176,6 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
label: 'Job Title', label: 'Job Title',
isComingSoon: true isComingSoon: true
}, },
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
isComingSoon: true
},
{ {
identifier: MarkType.DATETIME, identifier: MarkType.DATETIME,
icon: faClock, icon: faClock,
@ -266,6 +266,24 @@ 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[]) => {
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 optimizedSVG = optimize(svgContent, {
multipass: true, // Optimize multiple times if needed
floatPrecision: 2 // Adjust precision
} as Config)
return optimizedSVG.data
}
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 {
getCurrentUserMarks, getCurrentUserMarks,
filterMarksByPubkey, filterMarksByPubkey,

View File

@ -1,4 +1,4 @@
import { PdfPage } from '../types/drawing.ts' import { MarkType, PdfPage } from '../types/drawing.ts'
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib' import { 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'
@ -132,9 +132,17 @@ 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) => marksPerPage[i]?.forEach((mark) => {
switch (mark.type) {
case MarkType.SIGNATURE:
drawSignatureText(mark, pages[i])
break
default:
drawMarkText(mark, pages[i], robotoFont) drawMarkText(mark, pages[i], robotoFont)
) break
}
})
} }
} }
@ -245,3 +253,19 @@ async function embedFont(pdf: PDFDocument) {
const embeddedFont = await pdf.embedFont(fontBytes) const embeddedFont = await pdf.embedFont(fontBytes)
return embeddedFont return embeddedFont
} }
const drawSignatureText = (mark: Mark, page: PDFPage) => {
const { location } = mark
const { height } = page.getSize()
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
const x = location.left
const y = height - location.top
if (hasValue(mark)) {
const paths = JSON.parse(mark.value!)
paths.forEach((d: string) => {
page.drawSvgPath(d, { x, y })
})
}
}

View File

@ -1,9 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths' import tsconfigPaths from 'vite-tsconfig-paths'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths()], plugins: [
react(),
tsconfigPaths(),
nodePolyfills({
include: ['os']
})
],
build: { build: {
target: 'ES2022' target: 'ES2022'
} }