1// static/js/signature.js 2// 3// Reusable signature module. Two entry points: 4// capture(opts) â open a drawing modal, return Promise<Signature|null> 5// pick(opts) â show saved signatures + a "new" tile, return Promise<Signature|null> 6// 7// Signature shape: { id, dataUrl, width, height, name } 8// 9// Drawing uses a per-stroke smoother: each quadratic-bezier segment is anchored 10// at the previous point with control points at midpoints, which yields a 11// Catmull-Rom-like curve without external deps. Variable stroke width is 12// derived from pointer velocity (slower â thicker), which gives signatures 13// their characteristic ink-bleed feel. 14 15const API_BASE = window.location.origin; 16 17function _esc(s) { 18 return String(s ?? '') 19 .replace(/&/g, '&') 20 .replace(/</g, '<') 21 .replace(/>/g, '>') 22 .replace(/"/g, '"') 23 .replace(/'/g, '''); 24} 25 26function _safeSignatureDataUrl(raw) { 27 const value = String(raw || '').trim(); 28 return /^data:image\/png;base64,[a-z0-9+/=\s]+$/i.test(value) ? value : ''; 29} 30 31// Last signature the user picked or created in this session. Lets the export 32// modal pre-fill subsequent signature fields with the same one â sign once, 33// applies everywhere. 34let _lastUsed = null; 35export function getLastUsed() { return _lastUsed; } 36export function setLastUsed(sig) { _lastUsed = sig || null; } 37 38class SmoothPad { 39 constructor(canvas, opts = {}) { 40 this.canvas = canvas; 41 this.ctx = canvas.getContext('2d'); 42 this.minWidth = opts.minWidth ?? 1.0; 43 this.maxWidth = opts.maxWidth ?? 3.0; 44 this.color = opts.color ?? '#111'; 45 this.bgColor = opts.bgColor ?? '#fff'; 46 // Heavy-smoothing knobs (much smoother than v1): 47 // - minDistance: jitter floor (px) â bigger = more aggressive thinning 48 // - emaAlpha: live EMA on incoming points (lower = smoother, laggier) 49 // - chaikinIters: Chaikin corner-cutting passes on stroke-end redraw 50 // (each pass roughly doubles segment count and rounds every corner; 51 // 3 passes is silky, 4 is glass) 52 // - tension: secondary Catmull-Rom tightness for the final cubic curve 53 this.minDistance = opts.minDistance ?? 5.0; 54 this.emaAlpha = opts.emaAlpha ?? 0.15; 55 this.chaikinIters = opts.chaikinIters ?? 4; 56 this.tension = opts.tension ?? 0.35; 57 this._strokes = []; // array of { points: [{x,y,t,p}] } 58 this._current = null; 59 this._currentSmoothed = null; // EMA point used for live drawing 60 this._isEmpty = true; 61 this._wirePointer(); 62 this.clear(); 63 } 64 65 _wirePointer() { 66 const c = this.canvas; 67 c.style.touchAction = 'none'; 68 c.addEventListener('pointerdown', (e) => this._onDown(e)); 69 c.addEventListener('pointermove', (e) => this._onMove(e)); 70 c.addEventListener('pointerup', (e) => this._onUp(e)); 71 c.addEventListener('pointercancel', (e) => this._onUp(e)); 72 c.addEventListener('pointerleave', (e) => this._onUp(e)); 73 } 74 75 _toLocal(e) { 76 const r = this.canvas.getBoundingClientRect(); 77 const sx = this.canvas.width / r.width; 78 const sy = this.canvas.height / r.height; 79 return { 80 x: (e.clientX - r.left) * sx, 81 y: (e.clientY - r.top) * sy, 82 t: performance.now(), 83 p: e.pressure && e.pressure > 0 ? e.pressure : 0.5, 84 }; 85 } 86 87 _onDown(e) { 88 e.preventDefault(); 89 this.canvas.setPointerCapture(e.pointerId); 90 const raw = this._toLocal(e); 91 this._current = { points: [raw] }; 92 this._currentSmoothed = { ...raw }; 93 this._strokes.push(this._current); 94 this._isEmpty = false; 95 } 96 97 _onMove(e) { 98 if (!this._current) return; 99 e.preventDefault(); 100 const raw = this._toLocal(e); 101 const pts = this._current.points; 102 const last = pts[pts.length - 1]; 103 if (Math.hypot(raw.x - last.x, raw.y - last.y) < this.minDistance) return; 104 // EMA the incoming point against the running smoothed point so the live 105 // preview already reads as smooth (the final stroke-end pass will smooth 106 // further via Catmull-Rom). 107 const a = this.emaAlpha; 108 const sm = this._currentSmoothed; 109 const smoothed = { 110 x: sm.x + a * (raw.x - sm.x), 111 y: sm.y + a * (raw.y - sm.y), 112 t: raw.t, 113 p: sm.p + a * (raw.p - sm.p), 114 }; 115 this._currentSmoothed = smoothed; 116 pts.push(smoothed); 117 if (pts.length >= 3) { 118 this._drawSegment(pts[pts.length - 3], pts[pts.length - 2], pts[pts.length - 1]); 119 } else if (pts.length === 2) { 120 const [a0, b0] = pts; 121 this._strokeLine(a0, b0, this._widthBetween(a0, b0)); 122 } 123 } 124 125 _onUp(e) { 126 if (!this._current) return; 127 try { this.canvas.releasePointerCapture(e.pointerId); } catch (_) {} 128 // Heavy-smoothing finalize: redraw the just-finished stroke as a
129 // Catmull-Rom-fit cubic (much smoother than incremental quadratics). 130 const stroke = this._current; 131 this._current = null; 132 this._currentSmoothed = null; 133 if (stroke && stroke.points.length >= 2) { 134 this._repaintAll(); 135 } 136 } 137 138 _repaintAll() { 139 const ctx = this.ctx; 140 ctx.fillStyle = this.bgColor; 141 ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 142 for (const stroke of this._strokes) { 143 this._drawStrokeSmooth(stroke.points); 144 } 145 } 146 147 _chaikinPass(pts) { 148 // Each interior pair (a,b) becomes two new points at 1/4 and 3/4 along 149 // the segment. Endpoints preserved. 150 if (pts.length < 3) return pts; 151 const out = [pts[0]]; 152 for (let i = 0; i < pts.length - 1; i++) { 153 const a = pts[i], b = pts[i + 1]; 154 out.push({ 155 x: a.x * 0.75 + b.x * 0.25, 156 y: a.y * 0.75 + b.y * 0.25, 157 t: a.t, p: (a.p + b.p) / 2, 158 }); 159 out.push({ 160 x: a.x * 0.25 + b.x * 0.75, 161 y: a.y * 0.25 + b.y * 0.75, 162 t: b.t, p: (a.p + b.p) / 2, 163 }); 164 } 165 out.push(pts[pts.length - 1]); 166 return out; 167 } 168 169 _drawStrokeSmooth(pts) { 170 if (!pts || pts.length < 2) return; 171 if (pts.length === 2) { 172 this._strokeLine(pts[0], pts[1], this._widthBetween(pts[0], pts[1])); 173 return; 174 } 175 // Aggressive smoothing: N passes of Chaikin's corner-cutting, then a 176 // Catmull-Rom cubic through the densified points. 177 let smoothed = pts; 178 for (let i = 0; i < this.chaikinIters; i++) { 179 smoothed = this._chaikinPass(smoothed); 180 } 181 const ctx = this.ctx; 182 ctx.lineCap = 'round'; 183 ctx.lineJoin = 'round'; 184 ctx.strokeStyle = this.color; 185 const t = Math.max(0.05, Math.min(1, this.tension)); 186 const padded = [smoothed[0], ...smoothed, smoothed[smoothed.length - 1]]; 187 for (let i = 1; i < padded.length - 2; i++) { 188 const p0 = padded[i - 1]; 189 const p1 = padded[i]; 190 const p2 = padded[i + 1]; 191 const p3 = padded[i + 2]; 192 const c1 = { 193 x: p1.x + ((p2.x - p0.x) / 6) * t * 3, 194 y: p1.y + ((p2.y - p0.y) / 6) * t * 3, 195 }; 196 const c2 = { 197 x: p2.x - ((p3.x - p1.x) / 6) * t * 3, 198 y: p2.y - ((p3.y - p1.y) / 6) * t * 3, 199 }; 200 const w = this._widthBetween(p1, p2); 201 ctx.lineWidth = w; 202 ctx.beginPath(); 203 ctx.moveTo(p1.x, p1.y); 204 ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p2.x, p2.y); 205 ctx.stroke(); 206 } 207 } 208 209 _widthBetween(a, b) { 210 const dt = Math.max(1, b.t - a.t); 211 const dist = Math.hypot(b.x - a.x, b.y - a.y); 212 const v = dist / dt; // px/ms 213 // Slower strokes â thicker line. Tunable mapping. 214 const k = 1 / (1 + v * 4); 215 let w = this.minWidth + (this.maxWidth - this.minWidth) * k; 216 // Stylus pressure further modulates 217 const pAvg = (a.p + b.p) / 2; 218 w *= 0.6 + 0.8 * pAvg; 219 return Math.max(this.minWidth * 0.8, Math.min(this.maxWidth * 1.2, w)); 220 } 221 222 _strokeLine(a, b, width) { 223 const ctx = this.ctx; 224 ctx.lineCap = 'round'; 225 ctx.lineJoin = 'round'; 226 ctx.strokeStyle = this.color; 227 ctx.lineWidth = width; 228 ctx.beginPath(); 229 ctx.moveTo(a.x, a.y); 230 ctx.lineTo(b.x, b.y); 231 ctx.stroke(); 232 } 233 234 _drawSegment(p0, p1, p2) { 235 // Quadratic bezier from mid(p0,p1) to mid(p1,p2) with control p1. 236 const m1 = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 }; 237 const m2 = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }; 238 const w = this._widthBetween(p1, p2); 239 const ctx = this.ctx; 240 ctx.lineCap = 'round'; 241 ctx.lineJoin = 'round'; 242 ctx.strokeStyle = this.color; 243 ctx.lineWidth = w; 244 ctx.beginPath(); 245 ctx.moveTo(m1.x, m1.y); 246 ctx.quadraticCurveTo(p1.x, p1.y, m2.x, m2.y); 247 ctx.stroke(); 248 } 249 250 clear() { 251 const ctx = this.ctx; 252 ctx.save(); 253 ctx.fillStyle = this.bgColor; 254 ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); 255 ctx.restore(); 256 this._strokes = []; 257 this._current = null; 258 this._isEmpty = true; 259 } 260 261 undo() { 262 if (!this._strokes.length) return; 263 this._strokes.pop(); 264 this._repaintAll(); 265 this._isEmpty = this._strokes.length === 0; 266 } 267 268 isEmpty() { return this._isEmpty; } 269 270 // Trim transparent border so the saved signature isn't surrounded by 271 // empty space when stamped into a PDF rect. 272 toTrimmedDataUrl(padding = 4) { 273 const cw = this.canvas.width; 274 const ch = this.canvas.height; 275 const ctx = this.ctx; 276 const data = ctx.getImageData(0, 0, cw, ch).data; 277 let minX = cw, minY = ch, maxX = -1, maxY = -1; 278 // bgColor is opaque white â detect non-white pixels 279 for (let y = 0; y < ch; y++) { 280 for (let x = 0; x < cw; x++) { 281 const i = (y * cw + x) * 4; 282 if (data[i] < 250 || data[i + 1] < 250 || data[i + 2] < 250) { 283 if (x < minX) minX = x; 284 if (x > maxX) maxX = x; 285 if (y < minY) minY = y; 286 if (y > maxY) maxY = y; 287 } 288 } 289 } 290 if (maxX < 0) { 291 // empty 292 return { dataUrl: this.canvas.toDataURL('image/png'), width: cw, height: ch }; 293 } 294 minX = Math.max(0, minX - padding); 295 minY = Math.max(0, minY - padding); 296 maxX = Math.min(cw - 1, maxX + padding); 297 maxY = Math.min(ch - 1, maxY + padding); 298 const w = maxX - minX + 1; 299 const h = maxY - minY + 1; 300 const off = document.createElement('canvas'); 301 off.width = w; off.height = h; 302 const octx = off.getContext('2d'); 303 // Transparent background so the signature blends with the PDF 304 octx.drawImage(this.canvas, minX, minY, w, h, 0, 0, w, h); 305 // Replace white with transparent 306 const id = octx.getImageData(0, 0, w, h); 307 const px = id.data; 308 for (let i = 0; i < px.length; i += 4) { 309 if (px[i] > 245 && px[i + 1] > 245 && px[i + 2] > 245) { 310 px[i + 3] = 0; 311 } 312 } 313 octx.putImageData(id, 0, 0); 314 return { dataUrl: off.toDataURL('image/png'), width: w, height: h }; 315 } 316} 317 318function _modal(innerHtml) { 319 // Match the app's standard .modal pattern (defined in static/style.css).
320 const overlay = document.createElement('div'); 321 overlay.className = 'modal sig-modal-overlay'; 322 overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.45);z-index:10100;'; 323 overlay.innerHTML = innerHtml; 324 document.body.appendChild(overlay); 325 return overlay; 326} 327 328async function _listSignatures() { 329 const r = await fetch(`${API_BASE}/api/signatures`); 330 if (!r.ok) return []; 331 const data = await r.json(); 332 return data.signatures || []; 333} 334 335async function _saveSignature({ dataUrl, width, height, name }) { 336 const r = await fetch(`${API_BASE}/api/signatures`, { 337 method: 'POST', 338 headers: { 'Content-Type': 'application/json' }, 339 body: JSON.stringify({ data: dataUrl, width, height, name }), 340 }); 341 if (!r.ok) { 342 const t = await r.text(); 343 throw new Error(t || r.statusText); 344 } 345 return await r.json(); 346} 347 348async function _deleteSignature(id) { 349 await fetch(`${API_BASE}/api/signatures/${id}`, { method: 'DELETE' }); 350} 351 352// Smoothness slider maps a single 0â10 value to the two main knobs. 353// Persisted in localStorage so the user's preference sticks. 354const SMOOTH_KEY = 'odysseus.signature.smoothness'; 355function _loadSmoothness() {
356 const v = parseInt(localStorage.getItem(SMOOTH_KEY) || '', 10); 357 return Number.isFinite(v) && v >= 0 && v <= 10 ? v : 7; 358} 359function _saveSmoothness(v) { 360 try { localStorage.setItem(SMOOTH_KEY, String(v)); } catch (_) {} 361} 362function _smoothnessToParams(v) { 363 // 0 = raw, 10 = extreme glass 364 return { 365 minDistance: 1.0 + v * 0.6, // 1.0 â 7.0 366 emaAlpha: Math.max(0.05, 0.6 - v * 0.055), // 0.6 â 0.05 367 chaikinIters: Math.round(v * 0.6), // 0 â 6 368 }; 369} 370function _applySmoothness(pad, v) { 371 const p = _smoothnessToParams(v); 372 pad.minDistance = p.minDistance; 373 pad.emaAlpha = p.emaAlpha; 374 pad.chaikinIters = p.chaikinIters; 375 pad._repaintAll(); 376} 377 378// ââ capture: open the drawing modal, save on confirm âââââââââââââââââââââ 379export function capture(opts = {}) { 380 return new Promise((resolve) => { 381 const initialSmooth = _loadSmoothness(); 382 const overlay = _modal(` 383 <div class="modal-content" style="width:min(560px,94vw);"> 384 <div class="modal-header"> 385 <h4>Draw your signature</h4> 386 <button class="sig-close modal-close" title="Close">Ã</button> 387 </div> 388 <div class="modal-body"> 389 <canvas class="sig-canvas" width="900" height="280" data-no-swipe-dismiss></canvas> 390 <div style="margin-top:10px;display:flex;align-items:center;gap:10px;font-size:0.78rem;"> 391 <label for="sig-smoothness" style="white-space:nowrap;opacity:0.8;">Smoothness</label> 392 <input id="sig-smoothness" class="sig-smoothness" type="range" min="0" max="10" step="1" value="${initialSmooth}" style="flex:1;"> 393 <span class="sig-smoothness-val" style="width:18px;text-align:right;font-variant-numeric:tabular-nums;opacity:0.7;">${initialSmooth}</span> 394 </div> 395 <input class="sig-name" type="text" placeholder="Name (optional, e.g. 'Full' or 'Initials')" style="margin-top:10px;"> 396 </div> 397 <div class="modal-footer" style="display:flex;gap:8px;justify-content:flex-end;padding-top:8px;border-top:1px solid var(--border);margin-top:6px;"> 398 <button class="sig-clear confirm-btn confirm-btn-secondary">Clear</button> 399 <button class="sig-undo confirm-btn confirm-btn-secondary">Undo</button> 400 <span style="flex:1;"></span> 401 <button class="sig-cancel confirm-btn confirm-btn-secondary">Cancel</button> 402 <button class="sig-save confirm-btn confirm-btn-primary" disabled>Save</button> 403 </div> 404 </div> 405 `); 406 407 const canvas = overlay.querySelector('.sig-canvas'); 408 const pad = new SmoothPad(canvas, _smoothnessToParams(initialSmooth)); 409 const slider = overlay.querySelector('.sig-smoothness'); 410 const sliderVal = overlay.querySelector('.sig-smoothness-val'); 411 slider.addEventListener('input', () => { 412 const v = parseInt(slider.value, 10); 413 sliderVal.textContent = String(v); 414 _saveSmoothness(v); 415 _applySmoothness(pad, v); 416 }); 417 const saveBtn = overlay.querySelector('.sig-save'); 418 const nameInput = overlay.querySelector('.sig-name'); 419 420 const refreshSaveBtn = () => { saveBtn.disabled = pad.isEmpty(); }; 421 canvas.addEventListener('pointerup', refreshSaveBtn); 422 canvas.addEventListener('pointerleave', refreshSaveBtn); 423 canvas.addEventListener('pointercancel', refreshSaveBtn); 424 425 const close = (val) => { overlay.remove(); resolve(val); }; 426 overlay.querySelector('.sig-close').onclick = () => close(null); 427 overlay.querySelector('.sig-cancel').onclick = () => close(null); 428 overlay.querySelector('.sig-clear').onclick = () => { pad.clear(); refreshSaveBtn(); }; 429 overlay.querySelector('.sig-undo').onclick = () => { pad.undo(); refreshSaveBtn(); }; 430 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); }); 431 432 saveBtn.onclick = async () => { 433 saveBtn.disabled = true; 434 try { 435 const trimmed = pad.toTrimmedDataUrl(); 436 const sig = await _saveSignature({ 437 dataUrl: trimmed.dataUrl, 438 width: trimmed.width, 439 height: trimmed.height, 440 name: (nameInput.value || '').trim() || 'Signature', 441 }); 442 const out = { 443 id: sig.id, 444 dataUrl: sig.data_url, 445 width: sig.width, 446 height: sig.height, 447 name: sig.name, 448 }; 449 setLastUsed(out); 450 close(out); 451 } catch (e) { 452 alert('Failed to save signature: ' + e.message); 453 saveBtn.disabled = false;
454 } 455 }; 456 }); 457} 458 459// ââ pick: show saved signatures + new tile âââââââââââââââââââââââââââââââ 460export function pick(opts = {}) { 461 return new Promise(async (resolve) => { 462 const sigs = await _listSignatures(); 463 const tiles = sigs.map((s) => { 464 const dataUrl = _safeSignatureDataUrl(s.data_url); 465 if (!dataUrl) return ''; 466 return ` 467 <div class="sig-tile" data-id="${_esc(s.id)}"> 468 <img src="${_esc(dataUrl)}"/> 469 <div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div> 470 <button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">Ã</button> 471 </div> 472 `; 473 }).join(''); 474 475 const overlay = _modal(` 476 <div class="modal-content" style="width:min(560px,94vw);"> 477 <div class="modal-header"> 478 <h4>Choose a signature</h4> 479 <button class="sig-close modal-close" title="Close">Ã</button> 480 </div> 481 <div class="modal-body"> 482 <button class="sig-new-tile confirm-btn confirm-btn-primary" style="width:100%;margin-bottom:12px;padding:8px;">+ Draw new signature</button> 483 ${tiles ? `<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:10px;">${tiles}</div>` : '<div style="opacity:0.6;font-size:0.8rem;text-align:center;padding:8px 0;">No saved signatures yet â draw one above.</div>'} 484 </div> 485 </div> 486 `); 487 488 const close = (val) => { overlay.remove(); resolve(val); }; 489 overlay.querySelector('.sig-close').onclick = () => close(null); 490 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(null); }); 491 492 overlay.querySelectorAll('.sig-tile').forEach((tile) => { 493 tile.addEventListener('click', (e) => { 494 if (e.target.classList.contains('sig-tile-del')) return; 495 const id = tile.dataset.id; 496 const s = sigs.find((x) => x.id === id); 497 if (s) { 498 const dataUrl = _safeSignatureDataUrl(s.data_url); 499 if (!dataUrl) return; 500 const out = { id: s.id, dataUrl, width: s.width, height: s.height, name: s.name }; 501 setLastUsed(out); 502 close(out); 503 } 504 }); 505 }); 506 overlay.querySelectorAll('.sig-tile-del').forEach((btn) => { 507 btn.addEventListener('click', async (e) => { 508 e.stopPropagation(); 509 const id = btn.dataset.id; 510 if (!await window.styledConfirm('Delete this signature?', { confirmText: 'Delete', danger: true })) return; 511 await _deleteSignature(id); 512 btn.closest('.sig-tile')?.remove(); 513 }); 514 }); 515 overlay.querySelector('.sig-new-tile').onclick = async () => { 516 overlay.remove(); 517 const created = await capture(opts); 518 if (created) setLastUsed(created); 519 resolve(created); 520 }; 521 }); 522} 523 524export default { capture, pick, getLastUsed, setLastUsed };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.