上傳檔案到「/」
測試檔案
This commit is contained in:
+628
@@ -0,0 +1,628 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PDF 簽名工具</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
|
||||||
|
|
||||||
|
<!-- type="text/tailwindcss" required for @apply to work with Play CDN -->
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
/* ── Floating signature wrapper ───────────────────────────── */
|
||||||
|
.sig-el {
|
||||||
|
position: absolute; cursor: move; user-select: none;
|
||||||
|
}
|
||||||
|
.sig-el:hover { outline: 2px dashed #3b82f6; outline-offset: 2px; }
|
||||||
|
.sig-el:hover .sig-del { display: flex !important; }
|
||||||
|
.sig-el:hover .sig-rs { display: block !important; }
|
||||||
|
|
||||||
|
/* Resize handles */
|
||||||
|
.sig-rs {
|
||||||
|
display: none; position: absolute;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
background: #3b82f6; border: 2px solid #fff;
|
||||||
|
border-radius: 2px; z-index: 10;
|
||||||
|
}
|
||||||
|
.sig-rs-nw { top:-5px; left:-5px; cursor:nw-resize; }
|
||||||
|
.sig-rs-ne { top:-5px; right:-5px; cursor:ne-resize; }
|
||||||
|
.sig-rs-sw { bottom:-5px; left:-5px; cursor:sw-resize; }
|
||||||
|
.sig-rs-se { bottom:-5px; right:-5px; cursor:se-resize; }
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.sig-del {
|
||||||
|
display: none; position: absolute;
|
||||||
|
top:-12px; right:-12px; width:22px; height:22px;
|
||||||
|
background:#ef4444; border-radius:50%;
|
||||||
|
align-items:center; justify-content:center;
|
||||||
|
color:#fff; font-size:12px; cursor:pointer;
|
||||||
|
z-index:10; border:2px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar buttons ──────────────────────────────────────── */
|
||||||
|
.tbtn {
|
||||||
|
@apply px-3 py-1.5 rounded-lg text-sm font-medium transition cursor-pointer select-none;
|
||||||
|
}
|
||||||
|
.tbtn-blue { @apply tbtn bg-blue-500 hover:bg-blue-600 text-white; }
|
||||||
|
.tbtn-green { @apply tbtn bg-emerald-500 hover:bg-emerald-600 text-white; }
|
||||||
|
.tbtn-purple { @apply tbtn bg-violet-500 hover:bg-violet-600 text-white; }
|
||||||
|
.tbtn-orange { @apply tbtn bg-orange-500 hover:bg-orange-600 text-white; }
|
||||||
|
.tbtn-gray {
|
||||||
|
@apply tbtn bg-slate-100 hover:bg-slate-200 text-slate-600;
|
||||||
|
}
|
||||||
|
.tbtn-gray:disabled { @apply opacity-40 cursor-not-allowed; }
|
||||||
|
|
||||||
|
/* ── Pen type buttons ─────────────────────────────────────── */
|
||||||
|
.pen-btn {
|
||||||
|
@apply px-3 py-1.5 rounded-lg text-sm font-medium border-2 border-transparent
|
||||||
|
bg-slate-100 text-slate-600 transition cursor-pointer;
|
||||||
|
}
|
||||||
|
.pen-btn.active { @apply border-blue-500 bg-blue-50 text-blue-700; }
|
||||||
|
|
||||||
|
/* ── Color dot buttons ────────────────────────────────────── */
|
||||||
|
.cdot {
|
||||||
|
@apply w-9 h-9 rounded-full border-2 border-white shadow cursor-pointer
|
||||||
|
transition ring-2 ring-transparent ring-offset-1;
|
||||||
|
}
|
||||||
|
.cdot.active { @apply ring-blue-500; }
|
||||||
|
|
||||||
|
/* ── Small action buttons inside modal ───────────────────── */
|
||||||
|
.mbtn-red { @apply px-4 py-1.5 rounded-lg text-sm font-medium bg-red-100 hover:bg-red-200 text-red-600 transition; }
|
||||||
|
.mbtn-gray { @apply px-4 py-1.5 rounded-lg text-sm font-medium bg-slate-100 hover:bg-slate-200 text-slate-600 transition; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#pdf-container { position:relative; display:inline-block; line-height:0; }
|
||||||
|
#sig-canvas { touch-action:none; cursor:crosshair; display:block; }
|
||||||
|
#pdf-scroll { overflow:auto; max-height:calc(100vh - 180px); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-100 min-h-screen flex flex-col">
|
||||||
|
|
||||||
|
<!-- ═══════════════ HEADER TOOLBAR ═══════════════ -->
|
||||||
|
<header class="bg-white border-b shadow-sm sticky top-0 z-30">
|
||||||
|
<div class="max-w-screen-xl mx-auto px-4 py-2 flex flex-wrap items-center gap-2">
|
||||||
|
<div class="mr-2">
|
||||||
|
<h1 class="text-base font-bold text-slate-700 leading-tight">📄 PDF 簽名工具</h1>
|
||||||
|
<p class="text-[11px] text-slate-400 leading-tight">開發者:GinoHung</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="tbtn-blue">
|
||||||
|
📂 載入 PDF
|
||||||
|
<input type="file" accept=".pdf" id="upload-pdf" class="hidden" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="tbtn-green">
|
||||||
|
🖼️ 匯入簽名圖片
|
||||||
|
<input type="file" accept="image/*" id="upload-sig-img" class="hidden" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="btn-open-pad" class="tbtn-purple">✍️ 手寫簽名</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<button id="btn-prev" class="tbtn-gray" disabled>← 上頁</button>
|
||||||
|
<span id="page-label" class="text-xs text-slate-500 min-w-[90px] text-center">尚未載入</span>
|
||||||
|
<button id="btn-next" class="tbtn-gray" disabled>下頁 →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-export" class="tbtn-orange">💾 匯出 PDF</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══════════════ MAIN PDF AREA ═══════════════ -->
|
||||||
|
<main class="flex-1 flex flex-col items-center py-6 px-4">
|
||||||
|
|
||||||
|
<div id="empty-state"
|
||||||
|
class="w-full max-w-2xl flex flex-col items-center justify-center gap-3 h-72
|
||||||
|
border-2 border-dashed border-slate-300 rounded-2xl bg-white
|
||||||
|
text-slate-400 cursor-pointer hover:border-blue-400 transition"
|
||||||
|
onclick="document.getElementById('upload-pdf').click()">
|
||||||
|
<div class="text-5xl">📄</div>
|
||||||
|
<p class="text-lg font-medium">點擊或拖放 PDF 至此處開始</p>
|
||||||
|
<p class="text-sm">支援多頁 PDF・手寫簽名・PNG 圖片簽名</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pdf-scroll" class="hidden w-full justify-center">
|
||||||
|
<div id="pdf-container" class="shadow-2xl rounded-lg overflow-visible">
|
||||||
|
<canvas id="pdf-canvas" class="block rounded-lg"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══════════════ HANDWRITING MODAL ═══════════════ -->
|
||||||
|
<div id="modal-pad"
|
||||||
|
class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center
|
||||||
|
p-4 backdrop-blur-sm">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl flex flex-col">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-5 py-3 border-b">
|
||||||
|
<h2 class="font-bold text-slate-700">✍️ 手寫簽名</h2>
|
||||||
|
<button id="btn-close-pad" class="text-slate-400 hover:text-slate-600 text-2xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="px-5 py-3 border-b flex flex-wrap gap-5 items-end">
|
||||||
|
|
||||||
|
<!-- Pen type -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">筆觸</p>
|
||||||
|
<div class="flex gap-1" id="pen-type-group">
|
||||||
|
<button class="pen-btn active" data-pen="ballpoint">原子筆</button>
|
||||||
|
<button class="pen-btn" data-pen="fountain" >鋼筆</button>
|
||||||
|
<button class="pen-btn" data-pen="brush" >毛筆</button>
|
||||||
|
<button class="pen-btn" data-pen="pencil" >鉛筆</button>
|
||||||
|
<button class="pen-btn" data-pen="marker" >奇異筆</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">顏色</p>
|
||||||
|
<div class="flex gap-2 items-center" id="color-group">
|
||||||
|
<button class="cdot active" data-color="#111111" style="background:#111111;" title="黑色"></button>
|
||||||
|
<button class="cdot" data-color="#dc2626" style="background:#dc2626;" title="紅色"></button>
|
||||||
|
<button class="cdot" data-color="#16a34a" style="background:#16a34a;" title="綠色"></button>
|
||||||
|
<button class="cdot" data-color="#2563eb" style="background:#2563eb;" title="藍色"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stroke width 1–10 -->
|
||||||
|
<div class="flex-1 min-w-[130px]">
|
||||||
|
<p class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
|
||||||
|
粗細 <span id="width-val">3</span> px
|
||||||
|
</p>
|
||||||
|
<input type="range" id="stroke-width" min="1" max="10" value="3"
|
||||||
|
class="w-full accent-blue-500 cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas -->
|
||||||
|
<div class="px-5 py-4">
|
||||||
|
<div class="relative border-2 border-dashed border-slate-200 rounded-xl overflow-hidden bg-white">
|
||||||
|
<canvas id="sig-canvas" width="620" height="280" class="w-full"></canvas>
|
||||||
|
<p id="pad-hint"
|
||||||
|
class="absolute inset-0 flex items-center justify-center
|
||||||
|
text-slate-300 text-sm pointer-events-none select-none">
|
||||||
|
在此手寫您的簽名…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-between px-5 py-3 border-t">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="btn-clear" class="mbtn-red" >清除</button>
|
||||||
|
<button id="btn-undo" class="mbtn-gray">↩ 撤銷</button>
|
||||||
|
</div>
|
||||||
|
<button id="btn-apply" class="tbtn-blue">✓ 套用到 PDF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div id="toast"
|
||||||
|
class="hidden fixed bottom-6 right-6 bg-slate-800 text-white text-sm
|
||||||
|
px-5 py-3 rounded-xl shadow-xl z-50">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════ SCRIPT ═══════════════ -->
|
||||||
|
<script>
|
||||||
|
// ── Setup ────────────────────────────────────────────────────────
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
pdfJsDoc: null, rawBytes: null, origName: '',
|
||||||
|
curPage: 1, totalPages: 0, renderScale: 1.5,
|
||||||
|
sigs: [], sigId: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pad = {
|
||||||
|
drawing: false, penType: 'ballpoint',
|
||||||
|
color: '#111111', lineWidth: 3,
|
||||||
|
lastX: 0, lastY: 0, lastVel: 0,
|
||||||
|
history: [], hasContent: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── PDF loading ──────────────────────────────────────────────────
|
||||||
|
document.getElementById('upload-pdf').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
// Slice a copy for pdf-lib export; pass the original to pdf.js
|
||||||
|
// (pdf.js worker may transfer/detach the ArrayBuffer it receives)
|
||||||
|
state.rawBytes = new Uint8Array(buf.slice(0));
|
||||||
|
state.origName = file.name.replace(/\.pdf$/i, '');
|
||||||
|
try {
|
||||||
|
state.pdfJsDoc = await pdfjsLib.getDocument({ data: buf }).promise;
|
||||||
|
state.totalPages = state.pdfJsDoc.numPages;
|
||||||
|
state.curPage = 1;
|
||||||
|
state.sigs = [];
|
||||||
|
document.getElementById('empty-state').classList.add('hidden');
|
||||||
|
const scr = document.getElementById('pdf-scroll');
|
||||||
|
scr.classList.remove('hidden');
|
||||||
|
scr.classList.add('flex');
|
||||||
|
await renderPage(1);
|
||||||
|
toast(`PDF 已載入,共 ${state.totalPages} 頁`);
|
||||||
|
} catch(err) { toast('PDF 載入失敗:' + err.message, true); }
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag-and-drop on empty area
|
||||||
|
const emptyEl = document.getElementById('empty-state');
|
||||||
|
emptyEl.addEventListener('dragover', e => { e.preventDefault(); emptyEl.classList.add('border-blue-400'); });
|
||||||
|
emptyEl.addEventListener('dragleave', () => emptyEl.classList.remove('border-blue-400'));
|
||||||
|
emptyEl.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); emptyEl.classList.remove('border-blue-400');
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f?.type === 'application/pdf') {
|
||||||
|
const dt = new DataTransfer(); dt.items.add(f);
|
||||||
|
const inp = document.getElementById('upload-pdf');
|
||||||
|
inp.files = dt.files;
|
||||||
|
inp.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderPage(n) {
|
||||||
|
const page = await state.pdfJsDoc.getPage(n);
|
||||||
|
const viewport = page.getViewport({ scale: state.renderScale });
|
||||||
|
const cv = document.getElementById('pdf-canvas');
|
||||||
|
cv.width = viewport.width;
|
||||||
|
cv.height = viewport.height;
|
||||||
|
await page.render({ canvasContext: cv.getContext('2d'), viewport }).promise;
|
||||||
|
document.getElementById('page-label').textContent = `第 ${n} 頁 / 共 ${state.totalPages} 頁`;
|
||||||
|
document.getElementById('btn-prev').disabled = n <= 1;
|
||||||
|
document.getElementById('btn-next').disabled = n >= state.totalPages;
|
||||||
|
refreshSigElements(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-prev').addEventListener('click', () => { if (state.curPage > 1) renderPage(--state.curPage); });
|
||||||
|
document.getElementById('btn-next').addEventListener('click', () => { if (state.curPage < state.totalPages) renderPage(++state.curPage); });
|
||||||
|
|
||||||
|
// ── Floating signature elements ──────────────────────────────────
|
||||||
|
function addSig(src, page, x = 80, y = 80, w = 200, h = 80) {
|
||||||
|
const id = ++state.sigId;
|
||||||
|
state.sigs.push({ id, src, x, y, w, h, page });
|
||||||
|
mountSigEl(state.sigs.at(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSigElements(page) {
|
||||||
|
document.querySelectorAll('.sig-el').forEach(el => el.remove());
|
||||||
|
state.sigs.filter(s => s.page === page).forEach(s => mountSigEl(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountSigEl(sig) {
|
||||||
|
const wrap = document.getElementById('pdf-container');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'sig-el';
|
||||||
|
el.dataset.id = sig.id;
|
||||||
|
el.style.cssText = `left:${sig.x}px;top:${sig.y}px;width:${sig.w}px;height:${sig.h}px;`;
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = sig.src;
|
||||||
|
img.style.cssText = 'width:100%;height:100%;pointer-events:none;display:block;';
|
||||||
|
img.draggable = false;
|
||||||
|
el.appendChild(img);
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
const del = document.createElement('div');
|
||||||
|
del.className = 'sig-del'; del.innerHTML = '✕';
|
||||||
|
del.addEventListener('click', e => { e.stopPropagation(); state.sigs = state.sigs.filter(s => s.id !== sig.id); el.remove(); });
|
||||||
|
el.appendChild(del);
|
||||||
|
|
||||||
|
// Resize handles
|
||||||
|
['nw','ne','sw','se'].forEach(c => {
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = `sig-rs sig-rs-${c}`;
|
||||||
|
h.addEventListener('mousedown', e => startResize(e, el, sig, c));
|
||||||
|
el.appendChild(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
el.addEventListener('mousedown', e => { if (e.target === el || e.target === img) startDrag(e, el, sig); });
|
||||||
|
el.addEventListener('touchstart', e => { if (e.target === el || e.target === img) startDragTouch(e, el, sig); }, { passive:false });
|
||||||
|
wrap.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e, el, sig) {
|
||||||
|
e.preventDefault();
|
||||||
|
const ox = e.clientX - sig.x, oy = e.clientY - sig.y;
|
||||||
|
const mv = e => { sig.x = e.clientX - ox; sig.y = e.clientY - oy; el.style.left = sig.x+'px'; el.style.top = sig.y+'px'; };
|
||||||
|
const up = () => { removeEventListener('mousemove',mv); removeEventListener('mouseup',up); };
|
||||||
|
addEventListener('mousemove',mv); addEventListener('mouseup',up);
|
||||||
|
}
|
||||||
|
function startDragTouch(e, el, sig) {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = e.touches[0], ox = t.clientX - sig.x, oy = t.clientY - sig.y;
|
||||||
|
const mv = e => { const t=e.touches[0]; sig.x=t.clientX-ox; sig.y=t.clientY-oy; el.style.left=sig.x+'px'; el.style.top=sig.y+'px'; };
|
||||||
|
const up = () => { removeEventListener('touchmove',mv); removeEventListener('touchend',up); };
|
||||||
|
addEventListener('touchmove',mv,{passive:false}); addEventListener('touchend',up);
|
||||||
|
}
|
||||||
|
function startResize(e, el, sig, c) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const sx=e.clientX,sy=e.clientY,ox=sig.x,oy=sig.y,ow=sig.w,oh=sig.h;
|
||||||
|
const mv = e => {
|
||||||
|
const dx=e.clientX-sx, dy=e.clientY-sy;
|
||||||
|
if(c==='se'){sig.w=Math.max(30,ow+dx);sig.h=Math.max(20,oh+dy);}
|
||||||
|
if(c==='sw'){sig.w=Math.max(30,ow-dx);sig.h=Math.max(20,oh+dy);sig.x=ox+ow-sig.w;}
|
||||||
|
if(c==='ne'){sig.w=Math.max(30,ow+dx);sig.h=Math.max(20,oh-dy);sig.y=oy+oh-sig.h;}
|
||||||
|
if(c==='nw'){sig.w=Math.max(30,ow-dx);sig.h=Math.max(20,oh-dy);sig.x=ox+ow-sig.w;sig.y=oy+oh-sig.h;}
|
||||||
|
el.style.left=sig.x+'px';el.style.top=sig.y+'px';el.style.width=sig.w+'px';el.style.height=sig.h+'px';
|
||||||
|
};
|
||||||
|
const up = () => { removeEventListener('mousemove',mv); removeEventListener('mouseup',up); };
|
||||||
|
addEventListener('mousemove',mv); addEventListener('mouseup',up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import PNG signature ─────────────────────────────────────────
|
||||||
|
document.getElementById('upload-sig-img').addEventListener('change', e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!state.pdfJsDoc) { toast('請先載入 PDF', true); return; }
|
||||||
|
const rd = new FileReader();
|
||||||
|
rd.onload = ev => { addSig(ev.target.result, state.curPage); toast('圖片簽名已插入,可拖曳調整'); };
|
||||||
|
rd.readAsDataURL(file);
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Handwriting modal open/close ─────────────────────────────────
|
||||||
|
document.getElementById('btn-open-pad').addEventListener('click', () => {
|
||||||
|
if (!state.pdfJsDoc) { toast('請先載入 PDF', true); return; }
|
||||||
|
clearPad(); document.getElementById('modal-pad').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
document.getElementById('btn-close-pad').addEventListener('click', closePad);
|
||||||
|
document.getElementById('modal-pad').addEventListener('click', e => { if(e.target===e.currentTarget) closePad(); });
|
||||||
|
function closePad() { document.getElementById('modal-pad').classList.add('hidden'); }
|
||||||
|
|
||||||
|
// ── Pen type selection ───────────────────────────────────────────
|
||||||
|
document.getElementById('pen-type-group').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.pen-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
document.querySelectorAll('#pen-type-group .pen-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
pad.penType = btn.dataset.pen;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Color selection ──────────────────────────────────────────────
|
||||||
|
document.getElementById('color-group').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.cdot');
|
||||||
|
if (!btn) return;
|
||||||
|
document.querySelectorAll('#color-group .cdot').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
pad.color = btn.dataset.color;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stroke width ─────────────────────────────────────────────────
|
||||||
|
document.getElementById('stroke-width').addEventListener('input', e => {
|
||||||
|
pad.lineWidth = +e.target.value;
|
||||||
|
document.getElementById('width-val').textContent = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Canvas drawing ───────────────────────────────────────────────
|
||||||
|
const sigCv = document.getElementById('sig-canvas');
|
||||||
|
const sigCtx = sigCv.getContext('2d');
|
||||||
|
|
||||||
|
function clearPad() {
|
||||||
|
sigCtx.clearRect(0,0,sigCv.width,sigCv.height);
|
||||||
|
pad.history=[]; pad.hasContent=false;
|
||||||
|
document.getElementById('pad-hint').style.opacity='1';
|
||||||
|
}
|
||||||
|
function snapshot() {
|
||||||
|
pad.history.push(sigCtx.getImageData(0,0,sigCv.width,sigCv.height));
|
||||||
|
if(pad.history.length>40) pad.history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-clear').addEventListener('click', clearPad);
|
||||||
|
document.getElementById('btn-undo').addEventListener('click', () => {
|
||||||
|
if(!pad.history.length) return;
|
||||||
|
sigCtx.putImageData(pad.history.pop(),0,0);
|
||||||
|
if(!pad.history.length) pad.hasContent=false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function evPos(e) {
|
||||||
|
const r=sigCv.getBoundingClientRect(), sx=sigCv.width/r.width, sy=sigCv.height/r.height;
|
||||||
|
const s=e.touches?e.touches[0]:e;
|
||||||
|
return { x:(s.clientX-r.left)*sx, y:(s.clientY-r.top)*sy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// PEN ALGORITHMS
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/** 原子筆 – uniform width, full opacity, crisp round joins */
|
||||||
|
function drawBallpoint(x,y,lx,ly,color,w) {
|
||||||
|
sigCtx.globalAlpha=1; sigCtx.strokeStyle=color;
|
||||||
|
sigCtx.lineWidth=w; sigCtx.lineCap='round'; sigCtx.lineJoin='round';
|
||||||
|
sigCtx.beginPath(); sigCtx.moveTo(lx,ly); sigCtx.lineTo(x,y); sigCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 鋼筆 – fast → thin; slow/turning → thick; smooth quadratic curve */
|
||||||
|
function drawFountain(x,y,lx,ly,color,w,vel) {
|
||||||
|
const t = Math.max(0.2, Math.min(1, 1 - vel/25));
|
||||||
|
const dyn = w*0.4 + w*t*1.8;
|
||||||
|
sigCtx.globalAlpha=1; sigCtx.strokeStyle=color;
|
||||||
|
sigCtx.lineWidth=dyn; sigCtx.lineCap='round'; sigCtx.lineJoin='round';
|
||||||
|
sigCtx.beginPath(); sigCtx.moveTo(lx,ly);
|
||||||
|
sigCtx.quadraticCurveTo((lx+x)/2,(ly+y)/2,x,y); sigCtx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 毛筆 – very wide when slow/turning, razor-thin when fast
|
||||||
|
* • Main stroke with heavy velocity modulation (0.05 → 4× base width)
|
||||||
|
* • Soft alpha edges (two passes: outer 40% alpha, inner 85%)
|
||||||
|
* • Bristle spread: 4 thin parallel lines fan out from centre
|
||||||
|
* • Ink-bleed dot at segment endpoint for 飛白 feel
|
||||||
|
*/
|
||||||
|
function drawBrush(x,y,lx,ly,color,w,vel) {
|
||||||
|
const t = Math.max(0.05, Math.min(1, 1 - vel/18));
|
||||||
|
const thick = w*0.3 + w*t*4.5; // 0.3w ... 4.8w
|
||||||
|
const angle = Math.atan2(y-ly, x-lx) + Math.PI/2;
|
||||||
|
|
||||||
|
// Outer soft halo
|
||||||
|
sigCtx.globalAlpha = 0.30;
|
||||||
|
sigCtx.strokeStyle = color;
|
||||||
|
sigCtx.lineWidth = thick*1.45;
|
||||||
|
sigCtx.lineCap='round'; sigCtx.lineJoin='round';
|
||||||
|
sigCtx.beginPath(); sigCtx.moveTo(lx,ly);
|
||||||
|
sigCtx.quadraticCurveTo((lx+x)/2,(ly+y)/2,x,y);
|
||||||
|
sigCtx.stroke();
|
||||||
|
|
||||||
|
// Core stroke
|
||||||
|
sigCtx.globalAlpha = 0.88;
|
||||||
|
sigCtx.lineWidth = thick;
|
||||||
|
sigCtx.beginPath(); sigCtx.moveTo(lx,ly);
|
||||||
|
sigCtx.quadraticCurveTo((lx+x)/2,(ly+y)/2,x,y);
|
||||||
|
sigCtx.stroke();
|
||||||
|
|
||||||
|
// Bristle lines (fan spread perpendicular to stroke direction)
|
||||||
|
const offsets = [-0.55, -0.3, 0.3, 0.55];
|
||||||
|
offsets.forEach(ratio => {
|
||||||
|
const off = thick * ratio;
|
||||||
|
sigCtx.globalAlpha = Math.max(0.08, 0.25 * t);
|
||||||
|
sigCtx.lineWidth = Math.max(0.4, thick*0.08);
|
||||||
|
sigCtx.beginPath();
|
||||||
|
sigCtx.moveTo(lx + Math.cos(angle)*off, ly + Math.sin(angle)*off);
|
||||||
|
sigCtx.lineTo(x + Math.cos(angle)*off, y + Math.sin(angle)*off);
|
||||||
|
sigCtx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 飛白 ink-bleed dot at end of segment when moving fast
|
||||||
|
if (vel > 12) {
|
||||||
|
sigCtx.globalAlpha = 0.10 + Math.random()*0.08;
|
||||||
|
sigCtx.beginPath();
|
||||||
|
sigCtx.arc(x, y, thick*0.4, 0, Math.PI*2);
|
||||||
|
sigCtx.fillStyle = color; sigCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
sigCtx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 鉛筆 – low alpha, grainy texture that accumulates on overlap */
|
||||||
|
function drawPencil(x,y,lx,ly,color,w) {
|
||||||
|
const dist = Math.hypot(x-lx,y-ly) || 1;
|
||||||
|
const steps = Math.ceil(dist*1.5);
|
||||||
|
for(let i=0;i<=steps;i++){
|
||||||
|
const t=i/steps, px=lx+(x-lx)*t, py=ly+(y-ly)*t;
|
||||||
|
sigCtx.globalAlpha=0.32; sigCtx.fillStyle=color;
|
||||||
|
sigCtx.beginPath(); sigCtx.arc(px,py,w/2,0,Math.PI*2); sigCtx.fill();
|
||||||
|
if(Math.random()<0.45){
|
||||||
|
sigCtx.globalAlpha=0.10;
|
||||||
|
sigCtx.beginPath();
|
||||||
|
sigCtx.arc(px+(Math.random()-0.5)*w*1.8, py+(Math.random()-0.5)*w*1.8,
|
||||||
|
Math.random()*w*0.35+0.3, 0, Math.PI*2);
|
||||||
|
sigCtx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sigCtx.globalAlpha=1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 奇異筆 – wide, semi-transparent, square/miter edges */
|
||||||
|
function drawMarker(x,y,lx,ly,color,w) {
|
||||||
|
sigCtx.globalAlpha=0.65; sigCtx.strokeStyle=color;
|
||||||
|
sigCtx.lineWidth=w*3; sigCtx.lineCap='square'; sigCtx.lineJoin='miter';
|
||||||
|
sigCtx.beginPath(); sigCtx.moveTo(lx,ly); sigCtx.lineTo(x,y); sigCtx.stroke();
|
||||||
|
sigCtx.globalAlpha=1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas event wiring ──────────────────────────────────────────
|
||||||
|
function onDown(e){
|
||||||
|
e.preventDefault(); snapshot();
|
||||||
|
pad.drawing=true; pad.hasContent=true;
|
||||||
|
const p=evPos(e); pad.lastX=p.x; pad.lastY=p.y; pad.lastVel=0;
|
||||||
|
document.getElementById('pad-hint').style.opacity='0';
|
||||||
|
}
|
||||||
|
function onMove(e){
|
||||||
|
if(!pad.drawing) return; e.preventDefault();
|
||||||
|
const p=evPos(e), vel=Math.hypot(p.x-pad.lastX,p.y-pad.lastY);
|
||||||
|
const {penType:pt,color:col,lineWidth:lw,lastX:lx,lastY:ly}=pad;
|
||||||
|
if(pt==='ballpoint') drawBallpoint(p.x,p.y,lx,ly,col,lw);
|
||||||
|
else if(pt==='fountain') drawFountain(p.x,p.y,lx,ly,col,lw,vel);
|
||||||
|
else if(pt==='brush') drawBrush (p.x,p.y,lx,ly,col,lw,vel);
|
||||||
|
else if(pt==='pencil') drawPencil (p.x,p.y,lx,ly,col,lw);
|
||||||
|
else if(pt==='marker') drawMarker (p.x,p.y,lx,ly,col,lw);
|
||||||
|
pad.lastX=p.x; pad.lastY=p.y; pad.lastVel=vel;
|
||||||
|
}
|
||||||
|
function onUp(){ pad.drawing=false; }
|
||||||
|
|
||||||
|
sigCv.addEventListener('mousedown', onDown);
|
||||||
|
sigCv.addEventListener('mousemove', onMove);
|
||||||
|
sigCv.addEventListener('mouseup', onUp);
|
||||||
|
sigCv.addEventListener('mouseleave', onUp);
|
||||||
|
sigCv.addEventListener('touchstart', onDown, {passive:false});
|
||||||
|
sigCv.addEventListener('touchmove', onMove, {passive:false});
|
||||||
|
sigCv.addEventListener('touchend', onUp);
|
||||||
|
|
||||||
|
// ── Apply handwritten signature ──────────────────────────────────
|
||||||
|
document.getElementById('btn-apply').addEventListener('click', () => {
|
||||||
|
if(!pad.hasContent){ toast('請先在簽名板書寫',true); return; }
|
||||||
|
const imgData=sigCtx.getImageData(0,0,sigCv.width,sigCv.height);
|
||||||
|
const b=contentBounds(imgData);
|
||||||
|
if(!b){ toast('簽名板是空的',true); return; }
|
||||||
|
const pad2=8;
|
||||||
|
const cx=Math.max(0,b.x1-pad2), cy=Math.max(0,b.y1-pad2);
|
||||||
|
const cw=Math.min(sigCv.width, b.x2+pad2)-cx;
|
||||||
|
const ch=Math.min(sigCv.height,b.y2+pad2)-cy;
|
||||||
|
const tmp=document.createElement('canvas'); tmp.width=cw; tmp.height=ch;
|
||||||
|
tmp.getContext('2d').drawImage(sigCv,cx,cy,cw,ch,0,0,cw,ch);
|
||||||
|
const dispW=Math.min(cw,280), dispH=Math.round(ch/cw*dispW);
|
||||||
|
addSig(tmp.toDataURL('image/png'), state.curPage, 100, 100, dispW, dispH);
|
||||||
|
closePad(); clearPad();
|
||||||
|
toast('簽名已插入,可拖曳調整位置與大小');
|
||||||
|
});
|
||||||
|
|
||||||
|
function contentBounds(imgData){
|
||||||
|
const {data,width,height}=imgData;
|
||||||
|
let x1=width,y1=height,x2=0,y2=0,found=false;
|
||||||
|
for(let y=0;y<height;y++) for(let x=0;x<width;x++)
|
||||||
|
if(data[(y*width+x)*4+3]>8){
|
||||||
|
if(x<x1)x1=x; if(x>x2)x2=x; if(y<y1)y1=y; if(y>y2)y2=y; found=true;
|
||||||
|
}
|
||||||
|
return found?{x1,y1,x2,y2}:null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export PDF ───────────────────────────────────────────────────
|
||||||
|
document.getElementById('btn-export').addEventListener('click', async () => {
|
||||||
|
if(!state.rawBytes) { toast('請先載入 PDF',true); return; }
|
||||||
|
if(!state.sigs.length) { toast('尚未加入任何簽名',true); return; }
|
||||||
|
toast('正在產生 PDF…');
|
||||||
|
try {
|
||||||
|
const {PDFDocument}=PDFLib;
|
||||||
|
const doc=await PDFDocument.load(state.rawBytes);
|
||||||
|
for(const sig of state.sigs){
|
||||||
|
const page=doc.getPage(sig.page-1);
|
||||||
|
const {width:pW,height:pH}=page.getSize();
|
||||||
|
const pdfJsPage=await state.pdfJsDoc.getPage(sig.page);
|
||||||
|
const vp=pdfJsPage.getViewport({scale:state.renderScale});
|
||||||
|
const scX=pW/vp.width, scY=pH/vp.height;
|
||||||
|
const raw=sig.src.split(',')[1];
|
||||||
|
const bytes=Uint8Array.from(atob(raw),c=>c.charCodeAt(0));
|
||||||
|
const emb=sig.src.startsWith('data:image/png')
|
||||||
|
? await doc.embedPng(bytes)
|
||||||
|
: await doc.embedJpg(bytes);
|
||||||
|
page.drawImage(emb,{
|
||||||
|
x:sig.x*scX, y:pH-(sig.y+sig.h)*scY,
|
||||||
|
width:sig.w*scX, height:sig.h*scY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const out=await doc.save();
|
||||||
|
const url=URL.createObjectURL(new Blob([out],{type:'application/pdf'}));
|
||||||
|
Object.assign(document.createElement('a'),{href:url,download:`簽名_${state.origName}.pdf`}).click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast('PDF 匯出成功 ✓');
|
||||||
|
} catch(err){ toast('匯出失敗:'+err.message,true); console.error(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Toast ────────────────────────────────────────────────────────
|
||||||
|
let toastTm;
|
||||||
|
function toast(msg, isErr=false){
|
||||||
|
const el=document.getElementById('toast');
|
||||||
|
el.textContent=msg;
|
||||||
|
el.className=`fixed bottom-6 right-6 text-white text-sm px-5 py-3
|
||||||
|
rounded-xl shadow-xl z-50 ${isErr?'bg-red-600':'bg-slate-800'}`;
|
||||||
|
clearTimeout(toastTm);
|
||||||
|
toastTm=setTimeout(()=>el.classList.add('hidden'),3200);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user