PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/signature.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:53 UTC 19,382 bytes, 524 lines download raw bytes

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, '&amp;')
20    .replace(/</g, '&lt;')
21    .replace(/>/g, '&gt;')
22    .replace(/"/g, '&quot;')
23    .replace(/'/g, '&#39;');
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.