sign-pad
Version:
sign-pad web component provides a signature drawing surface and related services
317 lines (278 loc) • 8.48 kB
JavaScript
import { roundTo as r, extractSvgRawData, svgToCanvas } from './sign-pad-utils.js';
export {
LOCAL_NAME
}
const
LOCAL_NAME = new URL(import.meta.url).searchParams.get('local-name') || 'sign-pad',
SURFACE_CLASS = 'surface',
SVG_NAMESPACE = 'http://www.w3.org/2000/svg',
SURFACE = Symbol('surface'),
ACTIVE_POINTER = Symbol('active-pointer'),
EMPTY_STATE = Symbol('empty-state'),
CURRENT_GROUP = Symbol('current-group'),
CURRENT_POINT = Symbol('current-point'),
CURRENT_HOP = Symbol('current-hop'),
FULL_DIAG_SIZE = Symbol('full-diag-size'),
CHANGED_SINCE_ACTIVE = Symbol('changed-since-active'),
INERTION_POWER = 0.4,
INPUT_EVENT = 'input',
CHANGE_EVENT = 'change',
ATTRIBUTE_EMPTY = 'empty',
TRIM_KEY = 'trim',
INK_KEY = 'ink',
FILL_KEY = 'fill',
EXPORT_DEFAULTS = {
[TRIM_KEY]: false,
[INK_KEY]: '#000',
[FILL_KEY]: 'transparent'
},
EXPORT_FORMATS = {
svg: { defaultOptions: Object.assign({}, EXPORT_DEFAULTS) },
canvas: { defaultOptions: Object.assign({}, EXPORT_DEFAULTS) },
};
const TEMPLATE = document.createElement('template');
class Hop {
constructor(fp, tp) {
const dx = tp.x - fp.x;
const dy = tp.y - fp.y;
this.angle = Math.atan(dy / dx);
const diffs = this._calcRect(this.angle, fp.w, tp.w);
// from points
this.fpx1 = fp.x + diffs.fdx;
this.fpy1 = fp.y + diffs.fdy;
this.fpx2 = fp.x - diffs.fdx;
this.fpy2 = fp.y - diffs.fdy;
// to points
this.tpx1 = tp.x + diffs.tdx;
this.tpy1 = tp.y + diffs.tdy;
this.tpx2 = tp.x - diffs.tdx;
this.tpy2 = tp.y - diffs.tdy;
}
_calcRect(angle, fs, ts) {
const orthogAngle = angle + Math.PI / 2;
const c = Math.cos(orthogAngle);
const s = Math.sin(orthogAngle);
return {
fdx: c * fs / 2,
fdy: s * fs / 2,
tdx: c * ts / 2,
tdy: s * ts / 2
};
}
}
class SignPad extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).appendChild(TEMPLATE.content.cloneNode(true));
Object.defineProperties(this, {
[SURFACE]: { value: this.shadowRoot.querySelector(`.${SURFACE_CLASS}`) },
[ACTIVE_POINTER]: { value: null, writable: true },
[EMPTY_STATE]: { value: true, writable: true },
[CURRENT_GROUP]: { value: null, writable: true },
[CURRENT_POINT]: { value: null, writable: true },
[CURRENT_HOP]: { value: null, writable: true },
[FULL_DIAG_SIZE]: { value: null, writable: true },
[CHANGED_SINCE_ACTIVE]: { value: false, writable: true }
});
this._setupListeners();
}
connectedCallback() {
this.setAttribute(ATTRIBUTE_EMPTY, '');
}
get empty() {
return this[EMPTY_STATE];
}
clear() {
if (this[EMPTY_STATE]) {
return;
}
this[SURFACE].innerHTML = '';
this[EMPTY_STATE] = true;
this[CHANGED_SINCE_ACTIVE] = true;
this.setAttribute(ATTRIBUTE_EMPTY, '');
this.dispatchEvent(new Event(INPUT_EVENT));
}
export(format = EXPORT_FORMATS.SVG, options) {
if (!(format in EXPORT_FORMATS)) {
throw new Error(`unknown format '${format}'; use one of those: [${Object.keys(EXPORT_FORMATS).join(', ')}]`);
}
const eo = Object.assign({}, EXPORT_FORMATS[format].defaultOptions, options);
switch (format) {
case 'svg':
return this._exportSvg(eo);
case 'canvas':
return this._exportCanvas(eo);
}
}
_setupListeners() {
const s = this[SURFACE];
s.addEventListener('focus', e => this._onFocus(e));
s.addEventListener('blur', e => this._onBlur(e));
s.addEventListener('pointerdown', e => this._drawStart(e));
s.addEventListener('pointermove', e => this._drawMove(e));
s.addEventListener('pointerup', e => this._drawEnd(e));
s.addEventListener('pointerleave', e => this._drawEnd(e));
s.addEventListener('pointercancel', e => this._drawEnd(e));
s.addEventListener('keyup', e => this._keyProc(e));
}
_onFocus() {
this[CHANGED_SINCE_ACTIVE] = false;
}
_onBlur() {
if (this[CHANGED_SINCE_ACTIVE]) {
this.dispatchEvent(new Event(CHANGE_EVENT, { bubbles: true, composed: true }));
this[CHANGED_SINCE_ACTIVE] = false;
}
}
_drawStart(e) {
if (!e.isPrimary) { return; }
const pid = e.pointerId;
if (this[ACTIVE_POINTER] && pid !== this[ACTIVE_POINTER]) {
return;
}
e.target.setPointerCapture(pid);
this[ACTIVE_POINTER] = pid;
this[CURRENT_GROUP] = [];
this[CURRENT_POINT] = { x: e.offsetX, y: e.offsetY, w: 4 };
this[CURRENT_HOP] = null;
}
_drawEnd(e) {
if (e.pointerId === this[ACTIVE_POINTER]) {
e.target.releasePointerCapture(this[ACTIVE_POINTER]);
this[ACTIVE_POINTER] = null;
}
}
_keyProc(e) {
switch (e.code) {
case 'Escape':
this.clear();
break;
case 'Enter':
this.blur();
break;
}
}
_drawMove(e) {
if (e.pointerId !== this[ACTIVE_POINTER]) { return; }
const tp = { x: e.offsetX, y: e.offsetY };
const fp = this[CURRENT_POINT];
// calcs
const d = this._calcDistance(fp, tp);
if (d < 4) { return; }
tp.w = this._calcWeigth(d);
const hop = new Hop(fp, tp);
let adj = null;
let adj_to = null;
if (this[CURRENT_HOP]) {
let diff = hop.angle - this[CURRENT_HOP].angle;
if (Math.abs(diff) < Math.PI / 4) {
if (diff > Math.PI / 2) { diff -= Math.PI; }
if (diff < -Math.PI / 2) { diff += Math.PI; }
adj = diff * INERTION_POWER;
adj_to = hop.angle - adj;
}
}
// memorize
this[CURRENT_HOP] = hop;
this[CURRENT_POINT] = tp;
const cg = this[CURRENT_GROUP];
cg.push(hop);
// draw
if (cg.length > 1) {
this._paintJoin(cg[cg.length - 2], hop);
}
this._paintHop(hop, adj, adj_to, d);
// manage state & notify
this[CHANGED_SINCE_ACTIVE] = true;
if (this[EMPTY_STATE]) {
this[EMPTY_STATE] = false;
this.removeAttribute(ATTRIBUTE_EMPTY);
}
this.dispatchEvent(new Event(INPUT_EVENT));
}
_calcDistance(fp, tp) {
return Math.sqrt(Math.pow(tp.x - fp.x, 2) + Math.pow(tp.y - fp.y, 2));
}
_calcWeigth(ds) {
let fds = this[FULL_DIAG_SIZE];
if (!this[FULL_DIAG_SIZE]) {
const br = this.getBoundingClientRect();
fds = this[FULL_DIAG_SIZE] = Math.sqrt(Math.pow(br.width, 2) + Math.pow(br.height, 2));
}
return Math.max(2, 4 - ds / fds * 64);
}
_paintJoin(fh, th) {
const svgp = document.createElementNS(SVG_NAMESPACE, 'path');
svgp.setAttribute('d', `M ${r(fh.tpx1)} ${r(fh.tpy1)} L ${r(th.fpx1)} ${r(th.fpy1)} L ${r(fh.tpx2)} ${r(fh.tpy2)} L ${r(th.fpx2)} ${r(th.fpy2)} Z`);
this[SURFACE].appendChild(svgp);
}
_paintHop(h, ad, at, d) {
const svgp = document.createElementNS(SVG_NAMESPACE, 'path');
if (ad) {
const hd = (d / 2) / Math.cos(ad);
const dm = h.tpx1 >= h.fpx1 ? 1 : -1;
const dx = Math.cos(at) * hd * dm;
const dy = Math.sin(at) * hd * dm;
svgp.setAttribute('d', `M ${r(h.fpx1)} ${r(h.fpy1)} Q ${r(h.fpx1 + dx)} ${r(h.fpy1 + dy)} , ${r(h.tpx1)} ${r(h.tpy1)} L ${r(h.tpx2)} ${r(h.tpy2)} Q ${r(h.fpx2 + dx)} ${r(h.fpy2 + dy)} , ${r(h.fpx2)} ${r(h.fpy2)} Z`);
} else {
svgp.setAttribute('d', `M ${r(h.fpx1)} ${r(h.fpy1)} L ${r(h.tpx1)} ${r(h.tpy1)} L ${r(h.tpx2)} ${r(h.tpy2)} L ${r(h.fpx2)} ${r(h.fpy2)} Z`);
}
this[SURFACE].appendChild(svgp);
}
_exportSvg(opts) {
const rawData = extractSvgRawData(this[SURFACE]);
const result = document.createElementNS(SVG_NAMESPACE, 'svg');
for (const s of rawData.hops) {
result.appendChild(s);
}
const vb = opts[TRIM_KEY] ? rawData.drawRect : rawData.fullRect;
result.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
result.setAttribute('fill', opts[INK_KEY]);
if (opts[FILL_KEY] !== EXPORT_DEFAULTS[FILL_KEY]) {
result.setAttribute('style', `background:${opts[FILL_KEY]}`);
}
return result;
}
_exportCanvas(opts) {
const rawData = extractSvgRawData(this[SURFACE]);
const result = svgToCanvas(rawData, opts[INK_KEY], opts[FILL_KEY], opts[TRIM_KEY]);
return result;
}
}
TEMPLATE.innerHTML = `
<style>
:host {
display: inline-block;
min-width: 300px;
min-height: 200px;
width: 300px;
height: 200px;
}
.container {
position: relative;
width: 100%;
height: 100%;
}
.${SURFACE_CLASS},
[name="background"]::slotted(*) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
outline: none;
}
.${SURFACE_CLASS} {
fill: currentColor;
touch-action: none;
}
[name="background"]::slotted(*) {
pointer-events: none;
}
</style>
<div class="container">
<slot name="background"></slot>
<svg xmlns="http://www.w3.org/2000/svg" class="${SURFACE_CLASS}" aria-label="signature" tabindex="0"></svg>
</div>
`;
customElements.define(LOCAL_NAME, SignPad);