diff --git a/src/components/Signature/SignaturePad.tsx b/src/components/Signature/SignaturePad.tsx index a15d90a..9e386b8 100644 --- a/src/components/Signature/SignaturePad.tsx +++ b/src/components/Signature/SignaturePad.tsx @@ -4,7 +4,8 @@ import type Konva from 'konva' import { useUniversal } from '@unisim/sdk' import { useSignatureStore, type SignatureExtras } from '../../stores/signatureStore' import { useAnnotationStore } from '../../stores/annotationStore' -import { SIGNATURE_INK, formatSigningDate } from '../../lib/signature' +import { formatSigningDate } from '../../lib/signature' +import { inkColorFor, renderInkSignature } from '../../lib/renderInk' import { composeSignatureWithLabels } from '../../lib/composeSignature' import type { SignatureData } from '../../types/annotations' import { brandedQrPngDataUrl } from '../../lib/brandedQr' @@ -20,141 +21,6 @@ import { const PAD_W = 600 const PAD_H = 240 -// Small seeded PRNG (mulberry32) so the ink jitter/speckles are deterministic -// for a given drawing — no flicker, stable output. -function mulberry32(seed: number) { - let s = seed >>> 0 - return () => { - s = (s + 0x6d2b79f5) >>> 0 - let t = Math.imul(s ^ (s >>> 15), 1 | s) - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } -} - -// Stroke a smooth path through points using midpoint quadratics (matches the -// clean, tension-smoothed look of the on-screen preview). -function strokeSmooth(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[]) { - if (pts.length < 2) return - ctx.beginPath() - ctx.moveTo(pts[0].x, pts[0].y) - for (let i = 1; i < pts.length - 1; i++) { - const mx = (pts[i].x + pts[i + 1].x) / 2 - const my = (pts[i].y + pts[i + 1].y) / 2 - ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my) - } - ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y) - ctx.stroke() -} - -// Render the captured pen strokes. With `realistic` on, lays down deep-blue ink -// with a faint bleed, a shaky-hand wobble, per-segment width variation (thinner -// when moving fast) and a couple of speckles — subtle cues that read as a real -// signature. With it off, draws a clean uniform smoothed line. Returns a -// cropped PNG + its logical (CSS-px) size. -function renderInkSignature( - lines: number[][], - color: string, - realistic: boolean -): { dataUrl: string; width: number; height: number } | null { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity - for (const ln of lines) { - for (let i = 0; i < ln.length; i += 2) { - const x = ln[i], y = ln[i + 1] - if (x < minX) minX = x - if (y < minY) minY = y - if (x > maxX) maxX = x - if (y > maxY) maxY = y - } - } - if (!isFinite(minX)) return null - - const pad = 6 - minX -= pad; minY -= pad; maxX += pad; maxY += pad - const w = Math.max(1, maxX - minX) - const h = Math.max(1, maxY - minY) - const RS = 2 - - const canvas = document.createElement('canvas') - canvas.width = Math.ceil(w * RS) - canvas.height = Math.ceil(h * RS) - const ctx = canvas.getContext('2d')! - ctx.scale(RS, RS) - ctx.translate(-minX, -minY) - ctx.lineCap = 'round' - ctx.lineJoin = 'round' - ctx.strokeStyle = color - ctx.fillStyle = color - - const rnd = mulberry32(0x5eed) - const JITTER = realistic ? 0.6 : 0 - const BASE_W = 2.4 - - for (const ln of lines) { - const pts: { x: number; y: number }[] = [] - for (let i = 0; i < ln.length; i += 2) { - pts.push({ - x: ln[i] + (rnd() * 2 - 1) * JITTER, - y: ln[i + 1] + (rnd() * 2 - 1) * JITTER - }) - } - if (pts.length === 0) continue - if (pts.length === 1) { - ctx.globalAlpha = 0.9 - ctx.beginPath() - ctx.arc(pts[0].x, pts[0].y, BASE_W * 0.6, 0, Math.PI * 2) - ctx.fill() - continue - } - - // Clean mode: one uniform smoothed stroke, no bleed/variation/speckles. - if (!realistic) { - ctx.globalAlpha = 1 - ctx.lineWidth = 2.5 - strokeSmooth(ctx, pts) - continue - } - - // Faint, slightly-wider blurred underlay → ink bleed. - ctx.save() - ctx.globalAlpha = 0.16 - ctx.lineWidth = BASE_W * 1.9 - try { ctx.filter = 'blur(0.6px)' } catch { /* filter unsupported — plain wide line */ } - ctx.beginPath() - ctx.moveTo(pts[0].x, pts[0].y) - for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y) - ctx.stroke() - ctx.restore() - - // Main pass: per-segment width + subtle alpha/width noise. - for (let i = 1; i < pts.length; i++) { - const a = pts[i - 1], b = pts[i] - const d = Math.hypot(b.x - a.x, b.y - a.y) - let segW = BASE_W * (1 - Math.min(0.45, d / 45)) - segW *= 0.85 + rnd() * 0.3 - ctx.globalAlpha = 0.82 + rnd() * 0.18 - ctx.lineWidth = Math.max(0.8, segW) - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() - } - - // A couple of tiny ink speckles near the stroke. - const speckles = 1 + Math.floor(rnd() * 2) - for (let k = 0; k < speckles; k++) { - const p = pts[Math.floor(rnd() * pts.length)] - ctx.globalAlpha = 0.35 + rnd() * 0.3 - ctx.beginPath() - ctx.arc(p.x + (rnd() * 2 - 1) * 2, p.y + (rnd() * 2 - 1) * 2, 0.4 + rnd() * 0.7, 0, Math.PI * 2) - ctx.fill() - } - } - - ctx.globalAlpha = 1 - return { dataUrl: canvas.toDataURL('image/png'), width: w, height: h } -} - // iOS-style toggle used by the advanced options. function OptionToggle({ checked, @@ -312,7 +178,7 @@ export default function SignaturePad() { // Whether there's anything to place separately (gates the placement control). const hasExtras = (includeName && !!name.trim()) || includeDate // Ink colour follows the realism toggle (deep blue vs plain near-black). - const inkColor = realistic ? SIGNATURE_INK : '#0f172a' + const inkColor = inkColorFor(realistic) if (!open) return null @@ -363,10 +229,14 @@ export default function SignaturePad() { // Bake the ink (plus the field's requested name/date labels) into a single // PNG and drop it into the signature-request box the user clicked, then // return to the editor. Used when the pad was opened by clicking a field. + // `strokes` is the raw pen path behind `sig` when it came from this pad. It + // rides along so the placed signature's realism can still be toggled later; + // a phone/imported image arrives already rasterised and passes nothing. async function fillField( fieldId: string, sig: { dataUrl: string; width: number; height: number }, - trimmedName: string + trimmedName: string, + strokes?: number[][] ) { const ann = useAnnotationStore.getState().annotations.find((a) => a.id === fieldId) if (!ann || ann.type !== 'sigfield') { @@ -402,7 +272,9 @@ export default function SignaturePad() { showDate: wantDate, align: 'center', labelScale: 1, - color: inkColor + color: inkColor, + strokes: strokes && strokes.length > 0 ? strokes : undefined, + realistic } useAnnotationStore.getState().update(fieldId, { signed: { src, width: w, height: h, data } }) resetForm() @@ -421,7 +293,7 @@ export default function SignaturePad() { // instead of adding a reusable library signature. const fieldId = useSignatureStore.getState().signingFieldId if (fieldId) { - await fillField(fieldId, ink, trimmed) + await fillField(fieldId, ink, trimmed, lines) return } @@ -462,7 +334,11 @@ export default function SignaturePad() { showDate: wantDate && !separatePlacement, align: 'center', labelScale: 1, - color: inkColor + color: inkColor, + // Keep the pen path so realism stays a toggle after placement, not a + // decision frozen at draw time. + strokes: lines, + realistic } add({ name: sigName, dataUrl: finalUrl, width: finalW, height: finalH, extras, sig }) diff --git a/src/components/Viewer/AnnotationLayer.tsx b/src/components/Viewer/AnnotationLayer.tsx index 87e0f4d..cf8cb31 100644 --- a/src/components/Viewer/AnnotationLayer.tsx +++ b/src/components/Viewer/AnnotationLayer.tsx @@ -13,15 +13,17 @@ import { } from 'react-konva' import type Konva from 'konva' import { useAnnotationStore } from '../../stores/annotationStore' +import { usePdfStore } from '../../stores/pdfStore' import { useSignatureStore } from '../../stores/signatureStore' import { useCoarsePointer } from '../../hooks/useCoarsePointer' import { useImage } from '../../lib/useImage' import { RedactIcon } from '../icons/RedactIcon' import { SIGNATURE_INK, formatSigningDate } from '../../lib/signature' import { composeSignature, sigHasLabels } from '../../lib/composeSignature' +import { inkColorFor, renderInkSignature } from '../../lib/renderInk' import { FONT_CSS } from '../../lib/fonts' import { effectiveRuns, runFontStyle, runHasStyle, runsToPlainText, runsToHtml, parseRunsFromDom, mergeRuns } from '../../lib/textRuns' -import type { Annotation, DrawAnnotation, ImageAnnotation, SignatureData, SignatureFieldAnnotation, SigAlign, TextAnnotation, TextRun } from '../../types/annotations' +import type { Annotation, DrawAnnotation, ImageAnnotation, SignatureData, SignatureFieldAnnotation, SigAlign, TextAnnotation, Tool, TextRun } from '../../types/annotations' // On-screen font stacks, keyed by family id (shared with the toolbar + export). const FONT_STACK = FONT_CSS @@ -120,6 +122,61 @@ function getAnnotationBBox(a: Annotation): { x: number; y: number; width: number } } +// Tools that drop a new object and then stay armed, so the next click on empty +// page space places another one (ticking a column of checkboxes in a row). +// Because the fresh object is auto-selected, these are exactly the cases where +// the confirm affordance is worth showing next to Delete: it's the way out of +// the loop, back to Select, without hunting for the toolbar. 'select', +// 'marquee', 'hand', 'selecttext' and 'form' place nothing, so a selection made +// with them has nothing to confirm. +const PLACEMENT_TOOLS = new Set([ + 'text', + 'draw', + 'highlight', + 'rect', + 'ellipse', + 'redact', + 'tick', + 'cross', + 'line', + 'image', + 'signature', + 'sigfield' +]) + +// A newly placed object is sized in display pixels (`N / scale`) so it looks +// the same on screen at any zoom. Zoomed right out that stops making sense: at +// 25% a 160px-wide signature is 640pt on a 595pt-wide A4 page — wider than the +// sheet it lands on. Cap a placement at half the page in either direction, +// scaling both sides together so the aspect ratio survives. +const MAX_PLACEMENT_FRACTION = 0.5 + +function fitPlacement( + w: number, + h: number, + pageW: number, + pageH: number +): { width: number; height: number } { + const k = Math.min( + 1, + w > 0 ? (pageW * MAX_PLACEMENT_FRACTION) / w : 1, + h > 0 ? (pageH * MAX_PLACEMENT_FRACTION) / h : 1 + ) + return { width: w * k, height: h * k } +} + +// Where a Konva node sits when it is faithfully showing the model's position — +// the inverse of nodeMovePatch. Used to put a node back after a drag is +// cancelled (a pinch starting mid-drag), so the shape returns to where the user +// left it instead of committing a move they never meant to make. +function nodeHomePosition(a: Annotation): { x: number; y: number } { + // A pen stroke carries its position in `points`, so its node sits at origin. + if (a.type === 'draw') return { x: 0, y: 0 } + // Konva Ellipse is positioned by its centre; we store the top-left bbox. + if (a.type === 'ellipse') return { x: a.x + a.width / 2, y: a.y + a.height / 2 } + return { x: a.x, y: a.y } +} + // Highlighter strokes are free-draw lines that carry an opacity (pencil // strokes leave it undefined). They are intentionally left out of marquee // multi-select — the user can still grab them individually. @@ -509,6 +566,11 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro const setStrokeWidth = useAnnotationStore((s) => s.setStrokeWidth) const setFontSize = useAnnotationStore((s) => s.setFontSize) + // Two fingers are down on the viewer for a pinch-zoom (see PdfViewer). While + // this is true the layer starts nothing and finishes nothing — the gesture + // belongs entirely to the zoom. + const pinching = usePdfStore((s) => s.pinching) + const activeSignature = useSignatureStore((s) => { const id = s.activeId return id ? s.signatures.find((x) => x.id === id) ?? null : null @@ -520,6 +582,10 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro const drawingRef = useRef(false) const activePointerIds = useRef(new Set()) + // Set by cancelGesture; makes the dragend handlers discard the move instead + // of writing it to the store. Cleared when a fresh gesture legitimately + // starts (a new pointerdown or dragstart) or when the cancelled drag ends. + const gestureCancelled = useRef(false) const [currentLine, setCurrentLine] = useState(null) const [editingId, setEditingId] = useState(null) const [draggingId, setDraggingId] = useState(null) @@ -625,17 +691,75 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro return { x: p.x / scale, y: p.y / scale } } + // The page in the annotation store's own units (PDF points). `width`/`height` + // are display pixels at the current zoom, so dividing out the scale gives a + // zoom-independent page size to measure placements against. + const pageW = width / scale + const pageH = height / scale + + // Abandon every gesture in flight on this page and put anything mid-drag back + // where it started. A pinch is a zoom and only a zoom, so the drag / stroke / + // rubber-band the first finger began is discarded rather than half-committed. + // Also the right response to `pointercancel`, where the browser has taken the + // pointer away from us and no pointerup is coming. + function cancelGesture() { + drawingRef.current = false + setCurrentLine(null) + marqueeRef.current = null + setMarquee(null) + setLineDrag(null) + groupDragLast.current = null + // Tell the dragend handlers (Konva fires them from stopDrag, and line + // anchors fire theirs whenever the finger finally lifts) to drop the move. + gestureCancelled.current = true + const annos = useAnnotationStore.getState().annotations + for (const [id, node] of shapeRefs.current) { + if (!node.isDragging()) continue + node.stopDrag() + const ann = annos.find((a) => a.id === id) + if (ann) node.position(nodeHomePosition(ann)) + } + // Members of a group drag are moved directly by onShapeDragMove, so they + // are not "dragging" themselves and need putting back explicitly. + for (const id of useAnnotationStore.getState().selectedIds) { + const node = shapeRefs.current.get(id) + const ann = annos.find((a) => a.id === id) + if (node && ann) node.position(nodeHomePosition(ann)) + } + // A resize/rotate already under way is stopped where it stands rather than + // reverted — Konva's Transformer has no undo for a partial transform, and + // the alternative (letting it keep tracking finger one while the zoom + // changes underneath) is the dual action we're here to prevent. + trRef.current?.stopTransform() + trRef.current?.forceUpdate() + trRef.current?.getLayer()?.batchDraw() + setDraggingId(null) + } + + // A pinch can begin with its second finger on a different page (each page has + // its own Stage and pointer bookkeeping), so react to the viewer's flag too + // rather than relying on this layer seeing both fingers. + useEffect(() => { + if (pinching) cancelGesture() + // cancelGesture only touches refs + setState, so re-running solely on the + // pinch edge is what we want. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pinching]) + function onPointerDown(e: Konva.KonvaEventObject) { activePointerIds.current.add(e.evt.pointerId) - if (activePointerIds.current.size > 1) { + if (activePointerIds.current.size > 1 || pinching) { // Multi-touch (pinch-to-zoom) — abort any in-progress drawing stroke - // or rubber-band selection. - drawingRef.current = false - setCurrentLine(null) - marqueeRef.current = null - setMarquee(null) + // or rubber-band selection. The local pointer count catches a pinch + // that lands entirely on this page; `pinching` (set by the viewer's own + // touch handler) also catches one straddling two pages, where each + // layer only ever sees a single finger. + cancelGesture() return } + // A clean single-pointer gesture is starting — whatever a previous pinch + // cancelled is behind us. + gestureCancelled.current = false if (editingId) return // ignore stage events while typing if (tool === 'hand') return // pan handled by PdfViewer // Let Konva's Transformer own clicks on its anchors / rotate knob — @@ -752,16 +876,18 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro const sigState = useSignatureStore.getState() const active = sigState.signatures.find((x) => x.id === sigState.activeId) if (active) { - const targetW = 160 / scale const ratio = active.height / active.width + // Capped at half the page — see fitPlacement. The ghost preview runs + // the same numbers, so what you see under the cursor is what lands. + const fit = fitPlacement(160 / scale, (160 / scale) * ratio, pageW, pageH) add({ id: crypto.randomUUID(), pageIndex, type: 'image', - x: pos.x - targetW / 2, - y: pos.y - (targetW * ratio) / 2, - width: targetW, - height: targetW * ratio, + x: pos.x - fit.width / 2, + y: pos.y - fit.height / 2, + width: fit.width, + height: fit.height, src: active.dataUrl, // Carry the re-editable ink + options so the placed signature's // name/date can be changed (double-tap) / restyled (pill) later. @@ -785,16 +911,16 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro if (src) { const img = new Image() img.onload = () => { - const targetW = 200 / scale const ratio = img.naturalHeight / img.naturalWidth + const fit = fitPlacement(200 / scale, (200 / scale) * ratio, pageW, pageH) add({ id: crypto.randomUUID(), pageIndex, type: 'image', - x: pos.x - targetW / 2, - y: pos.y - (targetW * ratio) / 2, - width: targetW, - height: targetW * ratio, + x: pos.x - fit.width / 2, + y: pos.y - fit.height / 2, + width: fit.width, + height: fit.height, src }) useAnnotationStore.getState().setTool('select') @@ -805,6 +931,7 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro } function onPointerMove(e: Konva.KonvaEventObject) { + if (pinching) return const pos = getPos(e) if (marqueeRef.current) { const m = marqueeRef.current @@ -842,6 +969,16 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro onPointerUp() } + // The browser has taken this pointer over (it decided the touch was a scroll + // or a pinch). No pointerup is coming, so drop the id here — otherwise it + // sticks in the set and makes the next single-finger touch look like + // multi-touch — and abandon whatever it had started. + function onPointerCancel(e: Konva.KonvaEventObject) { + activePointerIds.current.delete(e.evt.pointerId) + setHoverPos(null) + cancelGesture() + } + function onPointerUp(e?: Konva.KonvaEventObject) { if (e) activePointerIds.current.delete(e.evt.pointerId) if (marqueeRef.current) { @@ -966,8 +1103,12 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro // Give a stray tap a sensible default-sized box so the field still // lands somewhere usable; a real drag uses the swept rectangle. const dragged = Math.abs(x2 - x1) > 8 && Math.abs(y2 - y1) > 8 - const w = dragged ? Math.abs(x2 - x1) : 200 / scale - const h = dragged ? Math.abs(y2 - y1) : 70 / scale + // A swept rectangle is explicit intent and kept as drawn; the tap + // default is a display-pixel size, so cap it against the page the same + // way a dropped signature is (see fitPlacement). + const tapFit = fitPlacement(200 / scale, 70 / scale, pageW, pageH) + const w = dragged ? Math.abs(x2 - x1) : tapFit.width + const h = dragged ? Math.abs(y2 - y1) : tapFit.height const x = dragged ? Math.min(x1, x2) : x1 - w / 2 const y = dragged ? Math.min(y1, y2) : y1 - h / 2 const sig = useSignatureStore.getState() @@ -1070,6 +1211,7 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro } function onShapeDragStart(a: Annotation) { + gestureCancelled.current = false setDraggingId(a.id) const ids = useAnnotationStore.getState().selectedIds if (ids.length > 1 && ids.includes(a.id)) { @@ -1083,6 +1225,7 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro // While dragging one member of a multi-selection, translate every other // selected node by the same delta so the group moves rigidly together. function onShapeDragMove(a: Annotation, e: Konva.KonvaEventObject) { + if (gestureCancelled.current) return const last = groupDragLast.current if (!last) return const ids = useAnnotationStore.getState().selectedIds @@ -1143,11 +1286,19 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro } function onLineAnchorDragMove(a: DrawAnnotation, index: 0 | 1, e: Konva.KonvaEventObject) { + if (gestureCancelled.current) return const { x, y } = resolveLineAnchor(a, index, e) setLineDrag({ id: a.id, index, x, y }) } function onLineAnchorDragEnd(a: DrawAnnotation, index: 0 | 1, e: Konva.KonvaEventObject) { + if (gestureCancelled.current) { + // Cancelled by a pinch — the anchor's x/y props snap it back to the + // line's untouched endpoint on the next render. + gestureCancelled.current = false + setLineDrag(null) + return + } const { x, y } = resolveLineAnchor(a, index, e) const points = [...a.points] points[index * 2] = x @@ -1158,6 +1309,13 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro function onShapeDragEnd(a: Annotation, e: Konva.KonvaEventObject) { setDraggingId(null) + if (gestureCancelled.current) { + // A pinch (or a browser pointercancel) took this gesture over. + // cancelGesture already put the nodes back; commit nothing. + gestureCancelled.current = false + groupDragLast.current = null + return + } const ids = useAnnotationStore.getState().selectedIds if (groupDragLast.current && ids.length > 1) { groupDragLast.current = null @@ -1336,7 +1494,9 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro setEditingId(null) } - const selectable = tool === 'select' + // Dragging is off while a pinch is running, so a second finger can never + // turn a zoom into a zoom-plus-drag. + const selectable = tool === 'select' && !pinching const cursor = tool === 'hand' ? 'grab' : tool === 'marquee' ? 'crosshair' : @@ -1351,10 +1511,18 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro // passive selecttext tool keep vertical panning + pinch-zoom available. const touchAction = (tool === 'select' || tool === 'form' || tool === 'hand' || tool === 'selecttext') ? 'pan-y pinch-zoom' : 'none' - const ghostSigWidth = 160 / scale - const ghostSigHeight = activeSignature - ? (ghostSigWidth * activeSignature.height) / activeSignature.width - : 0 + // Mirrors the drop sizing above (cap included) so the ghost under the cursor + // is exactly what gets placed. + const ghostSig = activeSignature + ? fitPlacement( + 160 / scale, + ((160 / scale) * activeSignature.height) / activeSignature.width, + pageW, + pageH + ) + : { width: 0, height: 0 } + const ghostSigWidth = ghostSig.width + const ghostSigHeight = ghostSig.height return ( <> @@ -1372,6 +1540,7 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} onPointerLeave={onPointerLeaveStage} > @@ -1739,13 +1908,15 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro a.id === selectedId) if (!selected) return null @@ -1860,28 +2038,42 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro // the page. They shouldn't get selected anyway, but guard here too. if (selected.type === 'sigfield' && selected.locked) return null const bbox = getAnnotationBBox(selected) + const left = (bbox.x + bbox.width) * scale + 8 + const top = bbox.y * scale - 8 return ( - + <> + + {PLACEMENT_TOOLS.has(tool) && ( + + )} + ) })()} @@ -2104,6 +2296,40 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro ) })()} + {(() => { + // Send-to-sign affordance on a selected "Sign here" box. Placing the + // boxes and emailing the document for signing are two halves of one + // job, and the dialog refuses to mint a link until at least one box + // exists — so the moment a box is on the page is exactly the moment to + // offer the next step, rather than sending the user back to + // Sign ▾ → Request → Send to sign. Sits under the Delete affordance, + // where a box/ellipse would carry its Fill toggle. + if (draggingId || editingId) return null + const selected = annotations.find((a) => a.id === selectedId) + if (!selected || selected.type !== 'sigfield') return null + if (selected.locked) return null + const bbox = getAnnotationBBox(selected) + return ( + + ) + })()} + {(() => { // Fill toggle follows the same visibility as the Delete affordance: any // single rect/ellipse selection (not while dragging or editing text), @@ -2389,9 +2615,9 @@ export default function AnnotationLayer({ pageIndex, width, height, scale }: Pro } // Double-tap options editor for a placed signature. Edits name / add-name / -// add-date only — size + alignment live on the on-canvas pill. Holds its own -// draft state so typing stays smooth while each change re-composes the image -// from the untouched ink. +// add-date, plus the realistic-ink look when the signature was drawn in-app — +// size + alignment live on the on-canvas pill. Holds its own draft state so +// typing stays smooth while each change re-composes the image from the ink. function SignatureOptionsModal({ data, onApply, @@ -2406,11 +2632,19 @@ function SignatureOptionsModal({ const [name, setName] = useState(data.name ?? '') const [showName, setShowName] = useState(!!data.showName) const [showDate, setShowDate] = useState(!!data.showDate) + const [realistic, setRealistic] = useState(!!data.realistic) // Latest base payload (ink + size/alignment) — re-read on every apply so the // pill's size/alignment edits aren't clobbered by a name/date change. const dataRef = useRef(data) dataRef.current = data + // The realism toggle is only offered for signatures drawn in-app, where the + // pen strokes were kept alongside the ink. An imported picture is just + // pixels — there is nothing to re-render, so the option stays hidden rather + // than pretending to do something. + const strokes = data.strokes + const canRestyle = !!strokes && strokes.length > 0 + const changeName = (v: string) => { setName(v) onApply({ ...dataRef.current, name: v, showName: showName || !!v, showDate }) @@ -2424,6 +2658,25 @@ function SignatureOptionsModal({ setShowDate(v) onApply({ ...dataRef.current, name, showName, showDate: v }) } + // Re-render the ink from the original strokes at the new realism setting and + // swap it in — the labels are then re-composited over it by applySigData. + // The ink colour follows the toggle exactly as it does in the pad, and the + // labels pick up the same colour so the two never drift apart. + const toggleRealistic = (v: boolean) => { + if (!strokes) return + const color = inkColorFor(v) + const ink = renderInkSignature(strokes, color, v) + if (!ink) return + setRealistic(v) + onApply({ + ...dataRef.current, + ink: ink.dataUrl, + inkWidth: ink.width, + inkHeight: ink.height, + color, + realistic: v + }) + } return createPortal(

- Changes the name and date only — your signature itself stays exactly as drawn. - Use the pill on the signature to resize or align the labels. + {canRestyle + ? 'Changes the labels and the pen style — the strokes you drew stay exactly as drawn. Use the pill on the signature to resize or align the labels.' + : 'Changes the name and date only — your signature itself stays exactly as drawn. Use the pill on the signature to resize or align the labels.'}

+ {canRestyle && ( + + )}
{onRedraw ? ( diff --git a/src/components/Viewer/PdfViewer.tsx b/src/components/Viewer/PdfViewer.tsx index 466c900..8cd12be 100644 --- a/src/components/Viewer/PdfViewer.tsx +++ b/src/components/Viewer/PdfViewer.tsx @@ -84,6 +84,10 @@ export default function PdfViewer() { function onStart(e: TouchEvent) { if (e.touches.length !== 2 || !el) return + // Announce the pinch so the annotation layer can abandon whatever the + // first finger had started — a two-finger gesture is a zoom and nothing + // else, never a zoom plus a half-committed drag. + usePdfStore.getState().setPinching(true) const [t1, t2] = [e.touches[0], e.touches[1]] const rect = el.getBoundingClientRect() pinch = { @@ -120,8 +124,13 @@ export default function PdfViewer() { }) } - function onEnd() { + function onEnd(e: TouchEvent) { pinch = null + // Stay in "pinching" until every finger is off the glass. Lifting just + // one would otherwise hand the remaining finger straight back to the + // annotation layer mid-gesture, which is exactly the accidental drag + // this flag exists to prevent. + if (e.touches.length === 0) usePdfStore.getState().setPinching(false) } el.addEventListener('touchstart', onStart, { passive: true }) @@ -133,6 +142,7 @@ export default function PdfViewer() { el.removeEventListener('touchmove', onMove) el.removeEventListener('touchend', onEnd) el.removeEventListener('touchcancel', onEnd) + usePdfStore.getState().setPinching(false) } }, []) @@ -177,6 +187,9 @@ export default function PdfViewer() { function onDown(e: PointerEvent) { if (!el) return if (e.button !== 0 && e.pointerType === 'mouse') return + // A pinch owns the scroll position (it anchors the zoom on the midpoint), + // so a pan running alongside it would fight the same two properties. + if (usePdfStore.getState().pinching) return dragging = true startX = e.clientX startY = e.clientY @@ -186,6 +199,12 @@ export default function PdfViewer() { } function onMove(e: PointerEvent) { if (!dragging || !el) return + // A pinch that starts mid-pan takes over: drop the pan rather than + // scrolling against the zoom's own scroll correction. + if (usePdfStore.getState().pinching) { + dragging = false + return + } el.scrollLeft = startScrollLeft - (e.clientX - startX) el.scrollTop = startScrollTop - (e.clientY - startY) } diff --git a/src/lib/renderInk.ts b/src/lib/renderInk.ts new file mode 100644 index 0000000..2ceb53d --- /dev/null +++ b/src/lib/renderInk.ts @@ -0,0 +1,149 @@ +// Rasterises captured pen strokes into a cropped PNG. Shared by the signature +// pad (which draws the strokes) and the on-canvas signature editor (which +// re-renders them when the "realistic" toggle is flipped after placement), so +// both produce byte-identical ink for the same strokes. + +import { SIGNATURE_INK } from './signature' + +// The plain, near-black line used when realism is off. +export const CLEAN_INK = '#0f172a' + +// Ink colour follows the realism toggle (deep blue vs plain near-black). +export function inkColorFor(realistic: boolean): string { + return realistic ? SIGNATURE_INK : CLEAN_INK +} + +// Small seeded PRNG (mulberry32) so the ink jitter/speckles are deterministic +// for a given drawing — no flicker, stable output. +function mulberry32(seed: number) { + let s = seed >>> 0 + return () => { + s = (s + 0x6d2b79f5) >>> 0 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Stroke a smooth path through points using midpoint quadratics (matches the +// clean, tension-smoothed look of the on-screen preview). +function strokeSmooth(ctx: CanvasRenderingContext2D, pts: { x: number; y: number }[]) { + if (pts.length < 2) return + ctx.beginPath() + ctx.moveTo(pts[0].x, pts[0].y) + for (let i = 1; i < pts.length - 1; i++) { + const mx = (pts[i].x + pts[i + 1].x) / 2 + const my = (pts[i].y + pts[i + 1].y) / 2 + ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my) + } + ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y) + ctx.stroke() +} + +// Render the captured pen strokes. With `realistic` on, lays down deep-blue ink +// with a faint bleed, a shaky-hand wobble, per-segment width variation (thinner +// when moving fast) and a couple of speckles — subtle cues that read as a real +// signature. With it off, draws a clean uniform smoothed line. Returns a +// cropped PNG + its logical (CSS-px) size. +export function renderInkSignature( + lines: number[][], + color: string, + realistic: boolean +): { dataUrl: string; width: number; height: number } | null { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const ln of lines) { + for (let i = 0; i < ln.length; i += 2) { + const x = ln[i], y = ln[i + 1] + if (x < minX) minX = x + if (y < minY) minY = y + if (x > maxX) maxX = x + if (y > maxY) maxY = y + } + } + if (!isFinite(minX)) return null + + const pad = 6 + minX -= pad; minY -= pad; maxX += pad; maxY += pad + const w = Math.max(1, maxX - minX) + const h = Math.max(1, maxY - minY) + const RS = 2 + + const canvas = document.createElement('canvas') + canvas.width = Math.ceil(w * RS) + canvas.height = Math.ceil(h * RS) + const ctx = canvas.getContext('2d')! + ctx.scale(RS, RS) + ctx.translate(-minX, -minY) + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.strokeStyle = color + ctx.fillStyle = color + + const rnd = mulberry32(0x5eed) + const JITTER = realistic ? 0.6 : 0 + const BASE_W = 2.4 + + for (const ln of lines) { + const pts: { x: number; y: number }[] = [] + for (let i = 0; i < ln.length; i += 2) { + pts.push({ + x: ln[i] + (rnd() * 2 - 1) * JITTER, + y: ln[i + 1] + (rnd() * 2 - 1) * JITTER + }) + } + if (pts.length === 0) continue + if (pts.length === 1) { + ctx.globalAlpha = 0.9 + ctx.beginPath() + ctx.arc(pts[0].x, pts[0].y, BASE_W * 0.6, 0, Math.PI * 2) + ctx.fill() + continue + } + + // Clean mode: one uniform smoothed stroke, no bleed/variation/speckles. + if (!realistic) { + ctx.globalAlpha = 1 + ctx.lineWidth = 2.5 + strokeSmooth(ctx, pts) + continue + } + + // Faint, slightly-wider blurred underlay → ink bleed. + ctx.save() + ctx.globalAlpha = 0.16 + ctx.lineWidth = BASE_W * 1.9 + try { ctx.filter = 'blur(0.6px)' } catch { /* filter unsupported — plain wide line */ } + ctx.beginPath() + ctx.moveTo(pts[0].x, pts[0].y) + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y) + ctx.stroke() + ctx.restore() + + // Main pass: per-segment width + subtle alpha/width noise. + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1], b = pts[i] + const d = Math.hypot(b.x - a.x, b.y - a.y) + let segW = BASE_W * (1 - Math.min(0.45, d / 45)) + segW *= 0.85 + rnd() * 0.3 + ctx.globalAlpha = 0.82 + rnd() * 0.18 + ctx.lineWidth = Math.max(0.8, segW) + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + } + + // A couple of tiny ink speckles near the stroke. + const speckles = 1 + Math.floor(rnd() * 2) + for (let k = 0; k < speckles; k++) { + const p = pts[Math.floor(rnd() * pts.length)] + ctx.globalAlpha = 0.35 + rnd() * 0.3 + ctx.beginPath() + ctx.arc(p.x + (rnd() * 2 - 1) * 2, p.y + (rnd() * 2 - 1) * 2, 0.4 + rnd() * 0.7, 0, Math.PI * 2) + ctx.fill() + } + } + + ctx.globalAlpha = 1 + return { dataUrl: canvas.toDataURL('image/png'), width: w, height: h } +} diff --git a/src/stores/pdfStore.ts b/src/stores/pdfStore.ts index 2d947d1..4b5aca8 100644 --- a/src/stores/pdfStore.ts +++ b/src/stores/pdfStore.ts @@ -76,6 +76,13 @@ interface PdfState { hostedStoreOpen: boolean sendToSignOpen: boolean ocrOpen: boolean + // True while two fingers are down on the viewer for a pinch-zoom. The + // annotation layer watches this so a pinch is only ever a zoom: any drag / + // stroke / rubber-band already in flight is abandoned, and no new one can + // start until the fingers lift. Lives here (not in the annotation store) + // because the gesture belongs to the viewport, and it has to be readable + // across every page's layer — a pinch can straddle two pages. + pinching: boolean // Advanced-menu dialogs that act on the currently-open document. mergeOpen: boolean convertOpen: boolean @@ -92,6 +99,7 @@ interface PdfState { setHostedStoreOpen: (open: boolean) => void setSendToSignOpen: (open: boolean) => void setOcrOpen: (open: boolean) => void + setPinching: (pinching: boolean) => void setMergeOpen: (open: boolean) => void setConvertOpen: (open: boolean) => void setMetadataOpen: (open: boolean) => void @@ -119,6 +127,7 @@ export const usePdfStore = create((set, get) => ({ hostedStoreOpen: false, sendToSignOpen: false, ocrOpen: false, + pinching: false, mergeOpen: false, convertOpen: false, metadataOpen: false, @@ -130,6 +139,7 @@ export const usePdfStore = create((set, get) => ({ setHostedStoreOpen: (hostedStoreOpen) => set({ hostedStoreOpen }), setSendToSignOpen: (sendToSignOpen) => set({ sendToSignOpen }), setOcrOpen: (ocrOpen) => set({ ocrOpen }), + setPinching: (pinching) => set({ pinching }), setMergeOpen: (mergeOpen) => set({ mergeOpen }), setConvertOpen: (convertOpen) => set({ convertOpen }), setMetadataOpen: (metadataOpen) => set({ metadataOpen }), diff --git a/src/types/annotations.ts b/src/types/annotations.ts index 452fede..103c2bf 100644 --- a/src/types/annotations.ts +++ b/src/types/annotations.ts @@ -153,6 +153,15 @@ export type SignatureData = SignatureLabelOptions & { ink: string inkWidth: number inkHeight: number + // The raw pen strokes the ink was rasterised from, in the pad's own pixel + // space — flat [x,y,x,y,…] arrays, one per stroke. Present only for + // signatures drawn in-app (the pad or the phone); an imported picture has no + // strokes. Keeping them lets `ink` be re-rendered after placement, which is + // what makes the "realistic" toggle re-editable rather than baked in forever. + strokes?: number[][] + // Whether `ink` was rendered with the realistic pen treatment. Only + // meaningful alongside `strokes` — without them the look can't be changed. + realistic?: boolean } export type ImageAnnotation = Base & {