import { effect, h, signal } from '@ui-arch/frame'; import { trackSize } from './watch.js'; import type { SignaturePadHandle, SignaturePadProps } from './types.js'; // 缺省画布高度与笔宽:160px 够签一个名字,2px 在 1x 屏上也不显细 const PAD_H = 160; const PEN_W = 2; /*** 签名板:指针手写,canvas 位图按 DPR 放大保证线条不糊,笔色读 canvas 的 color 走令牌;签字结果写回 value 信号 ***/ export function signaturePad(props: SignaturePadProps): SignaturePadHandle { // —— 状态集中声明 —— const height = props.height ?? PAD_H; // 画布 CSS 高度 const penW = props.penWidth ?? PEN_W; // 笔宽(CSS 像素) const signed = signal(false); // 是否已落笔:决定清空按钮可用性与「未签」提示 const canvas = h('canvas', { class: 'ua-sign-canvas', role: 'img', 'aria-label': props.ariaLabel ?? '手写签名区' }) as HTMLCanvasElement; const ctx = canvas.getContext('2d'); let drawing = false; // 指针是否按住 let lastX = 0; // 上一落点 x(CSS 像素坐标系) let lastY = 0; // 上一落点 y let snap = ''; // 尺寸变化前的内容快照:重设位图会清空画布,先存后用 /*** 取得当前笔色:读 canvas 计算样式的 color,颜色令牌换了笔色自动跟随 ***/ const penColor = (): string => getComputedStyle(canvas).color || '#000'; /*** 位图尺寸同步:按 CSS 宽 × DPR 设置位图,再把坐标系缩回 CSS 像素,避免高分屏线条发虚 ***/ const resize = (): void => { const cssW = canvas.clientWidth || canvas.parentElement?.clientWidth || 320; // 宽度跟随容器,取不到就退 320 const ratio = window.devicePixelRatio || 1; const nextW = Math.round(cssW * ratio); const nextH = Math.round(height * ratio); if (canvas.width === nextW && canvas.height === nextH) return; // 尺寸没变:不动位图(也不清内容) snap = signed() ? canvas.toDataURL() : ''; // 有内容才快照,空白不浪费一次编码 canvas.width = nextW; canvas.height = nextH; if (!ctx) return; ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // 之后的坐标都按 CSS 像素算 ctx.lineCap = 'round'; // 圆头:手写笔迹不该是方角 ctx.lineJoin = 'round'; if (!snap) return; const img = new Image(); img.onload = () => ctx.drawImage(img, 0, 0, cssW, height); // 快照按 CSS 尺寸贴回,缩放后笔迹仍在 img.src = snap; }; /*** 落笔点换算:指针坐标减画布位置,得到 CSS 像素坐标 ***/ const pointOf = (e: PointerEvent): [number, number] => { const box = canvas.getBoundingClientRect(); return [e.clientX - box.left, e.clientY - box.top]; }; /*** 画到新点:从上一落点连线,单点(点一下)也要画出一个圆点 ***/ const stroke = (x: number, y: number): void => { if (!ctx) return; ctx.strokeStyle = penColor(); ctx.lineWidth = penW; ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(x, y); ctx.stroke(); lastX = x; lastY = y; }; /*** 收笔:写回 dataURL,空画布不算签字 ***/ const commit = (): void => { const url = canvas.toDataURL('image/png'); if (props.value) props.value(url); if (props.onChange) props.onChange(url); }; /*** 清空:复位位图 + 状态 + 信号,一次到位(三处不同源会立刻不同步) ***/ const clear = (): void => { if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); signed(false); snap = ''; if (props.value) props.value(''); if (props.onChange) props.onChange(''); }; canvas.addEventListener('pointerdown', (e: PointerEvent) => { if (props.disabled === true) return; // 卫语句:布尔禁用直接挡住 drawing = true; canvas.setPointerCapture(e.pointerId); // 指针捕获:手滑出画布也继续跟笔 [lastX, lastY] = pointOf(e); stroke(lastX, lastY); // 先落一个点,单击也能留下痕迹 signed(true); }); canvas.addEventListener('pointermove', (e: PointerEvent) => { if (!drawing) return; // 卫语句:没按住不画 const [x, y] = pointOf(e); stroke(x, y); }); const finish = (): void => { if (!drawing) return; // 卫语句:未落笔不重复提交 drawing = false; commit(); }; canvas.addEventListener('pointerup', finish); canvas.addEventListener('pointercancel', finish); canvas.style.height = `${height}px`; // 高度只在这一次写:resize 里读它,不重复推导 const clearBtn = h('button', { type: 'button', class: 'ua-sign-clear', onClick: clear }, '清空') as HTMLButtonElement; effect(() => { clearBtn.disabled = !signed() || props.disabled === true || (typeof props.disabled === 'function' && Boolean(props.disabled())); }); // 空白或信号禁用时不给出无效操作 const el = h('div', { class: 'ua-sign' }, canvas, h('div', { class: 'ua-sign-bar' }, h('span', { class: 'ua-sign-hint' }, props.hint ?? '在上方区域手写签名'), clearBtn)); if (typeof props.disabled === 'function') effect(() => { el.classList.toggle('is-disabled', Boolean(props.disabled && typeof props.disabled === 'function' && props.disabled())); }); // 信号禁用:跟着变灰 if (props.disabled === true) el.classList.add('is-disabled'); trackSize(el, resize); // 容器宽度变化后重算位图,笔迹按快照贴回 requestAnimationFrame(resize); // 首帧:布局完成后再定位图尺寸,避免 clientWidth 为 0 return { el, clear, toDataURL: () => canvas.toDataURL('image/png'), isEmpty: () => !signed() }; }